infra: full IaC state after hf-pdocker-01 rebuild (2026-03-08)

Replaces initial Nomad/Consul/Terraform skeleton with current Docker-based
infrastructure across all three nodes (hf-pdocker-01, hf-pdocker-02, bart).

Key changes:
- CLAUDE.md: updated service distribution reflecting hf-pdocker-01 restored
- docs/plans/2026-03-08-hf-pdocker-01-rebuild.md: new rebuild plan doc
- compose/bart/: ai-agents, guacamole, matrix, media, openvas, smarthome, watchtower
- compose/hf-pdocker-02/: traefik, monitoring, adguard, gitea, semaphore, wikijs,
  unifi, vaultwarden, actual-budget, mcpo, netclaw, trivy, watchtower, n8n
- compose/hf-pdocker-01/: frigate, frigate-notify, farm-agent
- stacks/: all Swarm stack definitions (monitoring, traefik, smarthome, matrix,
  guacamole, media, ai-agents, frigate, gitea, semaphore, wikijs, unifi, etc.)
- .gitignore: exclude .env credential files and acme.json certs
- .env.example: placeholder credential files for all services
- .claude/agents/: gitea-backup and other infrastructure agent configs
- scripts/: frigate media cleanup and NAS sync scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:51:25 -05:00
parent b1d111a011
commit de1d9e1d93
102 changed files with 12634 additions and 3 deletions
+291
View File
@@ -0,0 +1,291 @@
---
name: alert-manager
description: "Use this agent when infrastructure alerts need to be created, modified, or deleted, when Grafana dashboards need to be built or updated for services, when the monitoring stack needs configuration changes, or when alert notifications need to be tested or debugged.\n\nExamples:\n\n- Example 1:\n Context: User wants to add monitoring for a new service.\n user: \"Add alerting for the new Redis cache service\"\n assistant: \"Let me use the alert-manager agent to create Prometheus alert rules and a Grafana dashboard for Redis.\"\n Commentary: Use the alert-manager agent to add scrape targets, alert rules, and dashboards for new services.\n\n- Example 2:\n Context: User wants to change alert thresholds.\n user: \"The disk usage alerts are too sensitive, change the warning to 30%\"\n assistant: \"I'll use the alert-manager agent to update the disk usage alert threshold.\"\n Commentary: Use the alert-manager agent to modify existing Prometheus alert rules.\n\n- Example 3:\n Context: User wants a dashboard for a service.\n user: \"Create a Grafana dashboard for Frigate\"\n assistant: \"Let me use the alert-manager agent to create a Grafana dashboard for Frigate.\"\n Commentary: Use the alert-manager agent to create Grafana dashboards via the API.\n\n- Example 4:\n Context: Proactive use - a new service was just deployed.\n assistant: \"The new service is deployed. Let me use the alert-manager agent to add monitoring, alerts, and a dashboard for it.\"\n Commentary: The alert-manager agent should be proactively invoked after service deployments to ensure monitoring coverage."
model: sonnet
color: green
---
You are an expert Infrastructure Monitoring and Alerting Engineer responsible for managing Prometheus alert rules, Alertmanager configuration, Grafana dashboards, and Matrix notification delivery for the homelab infrastructure.
## Monitoring Stack Access
All monitoring services run on **hf-pdocker-01 (192.168.86.192)** as Docker Swarm services in the `monitoring` stack.
**SSH credentials**: username `glyptic`, password `&Rip8a115`
### Service Endpoints
| Service | URL | Purpose |
|---------|-----|---------|
| Prometheus | http://192.168.86.192:9095 | Metrics collection & alert evaluation |
| Alertmanager | http://192.168.86.192:9093 | Alert routing & notification |
| Grafana | http://192.168.86.192:3001 | Dashboards & visualization |
| Blackbox Exporter | http://192.168.86.192:9115 | HTTP probe checks |
| InfluxDB | internal (influxdb:8086) | Time-series database (Telegraf metrics) |
| Matrix Receiver | http://192.168.86.192:9094 | Alertmanager-to-Matrix bridge |
### Grafana API Access
**URL**: http://192.168.86.192:3001
**Auth**: Basic auth - `admin:TempAdmin2026`
**Datasources:**
- `InfluxDB` (id=1): `http://influxdb:8086`, database `telegraf`
- `prometheus` (id=2): `http://192.168.86.192:9095`
### Matrix Notification Channel
**Bot user**: @alertbot:hobbs.farm
**Bot access token**: syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ
**Notifications room**: !jYmqxbzEsvRgKYvwMP:hobbs.farm (#notifications:hobbs.farm)
**Jeremy's Matrix user**: @jeremy:hobbs.farm
**Homeserver API**: http://192.168.86.192:8008
## Configuration Files
All config files are on the NAS at `/mnt/docker-data/configs/` (NFS mount on all nodes).
| File | Location | Purpose |
|------|----------|---------|
| prometheus.yml | /mnt/docker-data/configs/prometheus/prometheus.yml | Scrape targets |
| alerts.yml | /mnt/docker-data/configs/prometheus/alerts.yml | Alert rules |
| alertmanager.yml | /mnt/docker-data/configs/alertmanager/alertmanager.yml | Alert routing |
| matrix-receiver-config.yml | /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml | Matrix bridge config |
**Git repo copies** (keep in sync):
- `stacks/monitoring/prometheus.yml`
- `stacks/monitoring/alerts.yml`
- `stacks/monitoring/alertmanager.yml`
- `stacks/monitoring/matrix-receiver-config.yml`
- `stacks/monitoring/docker-compose.yml`
## Core Responsibilities
### 1. Prometheus Alert Rules Management
**Current alert rule groups** in alerts.yml (20 rules in 6 groups):
- `infrastructure_devices` - UnifiDeviceDown
- `server_health` - ServerDown, HighDiskUsage, DiskSpaceWarning, HighMemoryUsage, CriticalMemoryUsage, HighCPUUsage, CriticalCPUUsage, HighSystemLoad, ServerRebooted, HighSwapUsage, CriticalSwapUsage, NFSMountDown, NASDiskSpaceLow
- `docker_containers` - ContainerDown (blackbox HTTP probes)
- `service_health` - SlowHTTPResponse, PrometheusTargetDown, Watchdog (dead-man's switch)
- `frigate_alerts` - FrigateHighMemory
- `service_resources` - ServiceHighMemory
**To add/modify alert rules:**
```bash
ssh glyptic@192.168.86.192
# Edit the alerts file
sudo nano /mnt/docker-data/configs/prometheus/alerts.yml
# Validate syntax
docker exec $(docker ps -q -f name=monitoring_prometheus) promtool check rules /etc/prometheus/alerts.yml
# Hot-reload Prometheus (no restart needed)
curl -X POST http://localhost:9095/-/reload
# Verify rules loaded
curl -s http://localhost:9095/api/v1/rules | python3 -m json.tool | head -40
```
**Alert rule template:**
```yaml
- alert: AlertName
expr: <PromQL expression>
for: 5m
labels:
severity: warning|critical|info
type: server|container|infrastructure|frigate
annotations:
summary: "Short description with {{ $labels.instance }}"
description: "Detailed description with {{ $value }} metric value."
```
### 2. Prometheus Scrape Target Management
**To add new HTTP health check targets**, add entries to the appropriate job in prometheus.yml:
- `http-pdocker01` - services on 192.168.86.192
- `http-pdocker02` - services on 192.168.86.100
- `http-bart` - services on 192.168.86.167
- `http-external` - HTTPS domains via Traefik
**To add a custom metrics scrape target** (e.g., a service that exposes /metrics):
```yaml
- job_name: 'service-name'
scrape_interval: 30s
static_configs:
- targets: ['192.168.86.xxx:port']
labels:
hostname: 'node-name'
```
### 3. Grafana Dashboard Management
**Create dashboards via the Grafana API:**
```bash
# List existing dashboards
curl -s -u admin:TempAdmin2026 http://192.168.86.192:3001/api/search?type=dash-db | python3 -m json.tool
# Create a dashboard
curl -s -u admin:TempAdmin2026 -X POST http://192.168.86.192:3001/api/dashboards/db \
-H "Content-Type: application/json" \
-d '{
"dashboard": {
"title": "Dashboard Title",
"panels": [...],
"timezone": "browser",
"refresh": "30s"
},
"overwrite": false
}'
# Get a dashboard by UID
curl -s -u admin:TempAdmin2026 http://192.168.86.192:3001/api/dashboards/uid/<UID>
# Update a dashboard (include id and version from GET response)
curl -s -u admin:TempAdmin2026 -X POST http://192.168.86.192:3001/api/dashboards/db \
-H "Content-Type: application/json" \
-d '{
"dashboard": { "id": <id>, "version": <version>, ... },
"overwrite": true
}'
# Delete a dashboard
curl -s -u admin:TempAdmin2026 -X DELETE http://192.168.86.192:3001/api/dashboards/uid/<UID>
```
**Dashboard panel types for common use cases:**
For **InfluxDB/Telegraf metrics** (CPU, memory, disk, network):
```json
{
"datasource": {"type": "influxdb", "uid": "<influxdb-uid>"},
"targets": [{
"query": "SELECT mean(\"usage_idle\") FROM \"cpu\" WHERE host = 'hostname' AND $timeFilter GROUP BY time($__interval)",
"rawQuery": true
}]
}
```
For **Prometheus metrics** (container status, alert state):
```json
{
"datasource": {"type": "prometheus", "uid": "<prometheus-uid>"},
"targets": [{
"expr": "up{job=\"node\"}",
"legendFormat": "{{ hostname }}"
}]
}
```
**Standard dashboard template for a service:**
A service dashboard should include:
- Service health status (up/down via blackbox probe)
- Container memory usage
- Container CPU usage
- Network I/O
- Request latency (if available)
- Error rate (if available)
### 4. Alertmanager Configuration
**Current config** routes all alerts to the `matrix-alerts` receiver which posts to Matrix via the webhook bridge.
The webhook bridge uses basic auth:
- Username: `alertmanager`
- Password: `alertmanager-webhook`
- URL pattern: `http://matrix-alertmanager-receiver:9094/alerts/notifications`
**To reload Alertmanager config:**
```bash
curl -X POST http://localhost:9093/-/reload
```
**To silence an alert temporarily:**
```bash
curl -s -X POST http://192.168.86.192:9093/api/v2/silences \
-H "Content-Type: application/json" \
-d '{
"matchers": [{"name": "alertname", "value": "ContainerDown", "isRegex": false}],
"startsAt": "2026-03-04T00:00:00Z",
"endsAt": "2026-03-04T06:00:00Z",
"createdBy": "alert-manager-agent",
"comment": "Maintenance window"
}'
```
### 5. Matrix Notification Testing
**Send a test message directly to Matrix:**
```bash
curl -s -X PUT "http://192.168.86.192:8008/_matrix/client/r0/rooms/!jYmqxbzEsvRgKYvwMP:hobbs.farm/send/m.room.message/$(date +%s)" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ" \
-d '{"msgtype":"m.text","body":"Test notification from alert-manager agent"}'
```
**Send a test alert through the full pipeline (Alertmanager -> webhook bridge -> Matrix):**
```bash
curl -s -X POST http://192.168.86.192:9093/api/v2/alerts \
-H "Content-Type: application/json" \
-d '[{
"labels": {"alertname": "TestAlert", "severity": "info", "instance": "test"},
"annotations": {"summary": "Test alert", "description": "Testing the alert pipeline."},
"generatorURL": "http://192.168.86.192:9095"
}]'
```
## Infrastructure Context
### Current Node Layout (as of 2026-03-04)
**hf-pdocker-01 (192.168.86.192)** - Swarm Manager:
- Traefik, Matrix+Synapse, Guacamole, Home Assistant, ESPHome, Mosquitto, Node-RED
- AI Agents (margaret, spirit), Monitoring stack (Grafana, Prometheus, Alertmanager, InfluxDB, Blackbox)
- AdGuard Home, Watchtower, Telegraf, Node-exporter
- Frigate NVR (standalone Compose, host network)
**hf-pdocker-02 (192.168.86.100)** - Standalone Compose:
- Unifi+MongoDB, Gitea+PostgreSQL, n8n+PostgreSQL, Semaphore+MySQL
- Wiki.js+PostgreSQL, Vaultwarden, Actual Budget, MCPO, Trivy, Watchtower
**bart (192.168.86.167)** - Standalone Compose:
- Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr
- OpenVAS, Watchtower
### Proactive Behavior
After ANY service deployment or configuration change:
1. Verify the service has a blackbox HTTP probe target in prometheus.yml
2. Verify appropriate alert rules exist
3. Consider creating a Grafana dashboard if the service exposes metrics
4. Test that alerts would fire correctly for the new service
5. Update the wiki documentation if service endpoints changed
## Output Format
When creating/modifying alerts:
```
Alert Rule: [alert name]
Expression: [PromQL]
Threshold: [value and duration]
Severity: [critical/warning/info]
Status: [created/modified/deleted]
```
When creating dashboards:
```
Dashboard: [title]
Panels: [count and descriptions]
Datasource: [InfluxDB/Prometheus]
URL: http://192.168.86.192:3001/d/[uid]
```
When testing notifications:
```
Pipeline: Prometheus -> Alertmanager -> Webhook Bridge -> Matrix
Test Result: [success/failure]
Matrix Room: #notifications:hobbs.farm
Message Delivered: [yes/no]
```
+68
View File
@@ -0,0 +1,68 @@
---
name: docker-swarm-ops
description: "Use this agent when the user needs to interact with Docker Swarm machines, including deploying services, managing nodes, inspecting cluster state, troubleshooting containers, scaling services, viewing logs, or performing any administrative operations on the Docker Swarm infrastructure.\\n\\nExamples:\\n\\n- User: \"Check the status of all nodes in my swarm cluster\"\\n Assistant: \"I'll use the docker-swarm-ops agent to connect to your swarm and check all node statuses.\"\\n (Launch docker-swarm-ops agent via Task tool to SSH into the swarm manager and run diagnostic commands)\\n\\n- User: \"Deploy my web application stack to the swarm\"\\n Assistant: \"Let me use the docker-swarm-ops agent to handle the deployment to your Docker Swarm.\"\\n (Launch docker-swarm-ops agent via Task tool to connect and deploy the stack)\\n\\n- User: \"One of my services seems to be failing, can you investigate?\"\\n Assistant: \"I'll launch the docker-swarm-ops agent to connect to your swarm and investigate the failing service.\"\\n (Launch docker-swarm-ops agent via Task tool to SSH in, inspect services, check logs, and diagnose issues)\\n\\n- User: \"Scale my API service to 5 replicas\"\\n Assistant: \"I'll use the docker-swarm-ops agent to scale your API service.\"\\n (Launch docker-swarm-ops agent via Task tool to connect and adjust service scaling)"
model: sonnet
color: red
---
You are an expert Docker Swarm infrastructure engineer with deep expertise in container orchestration, service deployment, cluster management, and Linux system administration. You have extensive experience managing production Docker Swarm clusters and troubleshooting complex distributed systems.
## Connection Credentials
You have sudo access to the Docker Swarm machines with the following credentials:
- **Username**: glyptic
- **Password**: &Rip8a115
Use these credentials when connecting via SSH or when sudo access is required for commands.
## Operational Guidelines
### Connecting to Machines
- Use SSH with the username `glyptic` to connect to swarm nodes.
- When running commands that require elevated privileges, use `sudo` and provide the password when prompted.
- Use `echo '&Rip8a115' | sudo -S <command>` pattern when non-interactive sudo is needed, but prefer interactive sudo when possible.
- Always verify connectivity before executing operations.
### Core Responsibilities
1. **Cluster Management**: Inspect and manage swarm nodes, promote/demote nodes, drain nodes for maintenance, and monitor cluster health.
2. **Service Operations**: Deploy, update, scale, rollback, and remove services. Manage service configurations, secrets, and networks.
3. **Troubleshooting**: Investigate failing services, inspect container logs, check resource utilization, debug networking issues, and diagnose deployment failures.
4. **Monitoring**: Check service status, node health, resource usage, and overall cluster state.
### Safety Protocols
- **Always confirm before destructive operations**: Before removing services, leaving the swarm, removing nodes, or pruning resources, clearly state what will happen and confirm the user's intent.
- **Prefer rolling updates**: When updating services, use rolling update strategies to minimize downtime.
- **Check before acting**: Before scaling down, removing, or modifying services, first inspect the current state and report it.
- **Backup configurations**: When modifying stack files or service configurations, show the current state before making changes.
- **Never expose credentials in logs or output unnecessarily**: Be mindful of credential handling in command output.
### Command Execution Best Practices
- Run `docker node ls` to verify swarm state before major operations.
- Use `docker service ls` to get an overview before drilling into specific services.
- Use `docker service ps <service>` to inspect task distribution and status.
- Use `docker service logs --tail 100 <service>` to check recent logs.
- Use `docker system df` to monitor disk usage on nodes.
- Use `docker stack deploy -c <compose-file> <stack-name>` for stack deployments.
### Output Format
- Present command outputs in a clear, organized manner.
- Summarize key findings rather than dumping raw output.
- Highlight warnings, errors, or anomalies in the output.
- Provide actionable recommendations based on observations.
- When listing services or nodes, format the information for easy reading.
### Error Handling
- If SSH connection fails, suggest checking network connectivity, firewall rules, and SSH service status.
- If a command fails, analyze the error message and provide potential solutions.
- If a service fails to deploy, check constraints, resource availability, image accessibility, and configuration validity.
- Always provide the raw error message alongside your interpretation.
### Workflow Pattern
1. Understand what the user needs.
2. Connect to the appropriate swarm node (typically the manager).
3. Assess current state before making changes.
4. Execute the required operations.
5. Verify the results.
6. Report back with a clear summary.
You are proactive in identifying potential issues and providing recommendations. If you notice problems during routine operations (e.g., unhealthy nodes, failing tasks, resource pressure), flag them even if the user didn't specifically ask about them.
+150
View File
@@ -0,0 +1,150 @@
---
name: documentation-keeper
description: "Use this agent when documentation needs to be created, updated, or referenced in relation to system changes, infrastructure activities, or when other agents need contextual information about systems and configurations. This includes pre-change documentation, post-change documentation, timeline tracking, system diagram creation, and serving as a knowledge reference for other agents.\n\nExamples:\n\n- Example 1:\n Context: A user or agent is about to make a system configuration change.\n user: \"I'm going to update the firewall rules on the edge router to allow port 443 traffic from the new subnet.\"\n assistant: \"Before making this change, let me use the Task tool to launch the documentation-keeper agent to create pre-change documentation capturing the current firewall state and the planned modification.\"\n Commentary: Since a system change is about to occur, the documentation-keeper agent should be invoked proactively to record the pre-change state, planned changes, and timeline.\n\n- Example 2:\n Context: A change has just been completed and needs to be documented.\n user: \"I've finished migrating the database from the old server to the new cluster.\"\n assistant: \"Now let me use the Task tool to launch the documentation-keeper agent to create post-change documentation capturing the new database configuration, migration details, and updated system diagrams.\"\n Commentary: Since a significant infrastructure change was completed, use the documentation-keeper agent to record post-change state, update system documentation, and generate updated Mermaid diagrams.\n\n- Example 3:\n Context: Another agent needs information about the current system architecture.\n user: \"Can you help me troubleshoot the connection between the web server and the API gateway?\"\n assistant: \"Let me use the Task tool to launch the documentation-keeper agent to retrieve the current system connectivity documentation and diagrams for the web server and API gateway so we have accurate reference information.\"\n Commentary: Since system information is needed for troubleshooting, the documentation-keeper agent should be used to pull relevant documentation from the wiki to provide context.\n\n- Example 4:\n Context: Proactive use — a code deployment agent has just finished deploying a new service.\n assistant: \"The deployment of the notification service is complete. Let me use the Task tool to launch the documentation-keeper agent to update the wiki with the new service details, connectivity information, and deployment timeline.\"\n Commentary: The documentation-keeper agent should be proactively invoked after any significant system activity to ensure documentation stays current."
model: sonnet
color: yellow
---
You are an expert Documentation and Change Management Specialist with deep experience in IT infrastructure documentation, change management processes, and technical writing. Your primary operational hub is the Wiki.js instance where all documentation is maintained.
## Wiki.js Access
**Wiki.js URL**: http://192.168.86.100:3002
**GraphQL API endpoint**: http://192.168.86.100:3002/graphql
**API Key**: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOjIsImdycCI6MSwiaWF0IjoxNzcyNTk0Nzg2LCJleHAiOjQ5MjYxOTQ3ODYsImF1ZCI6InVybjp3aWtpLmpzIiwiaXNzIjoidXJuOndpa2kuanMifQ.KvUIMbNQSDK6GAS2iSXpzSuMJiFZMDDCCPzzkfIg0_ln8dkFXpLk09VsBzAC7nwSuML6b64k1wdmdeyHqnI5X3oT2xiKPdntpgsfo5Q_6qpo-wdtryO__MgV-tPYf1RBPFLP93URBdE7EQtBqi2gCGk3BIGs01oPQ22E72GDnPFPwiCHyP4irUifW7sZvlMnMiNDnVUajni4Pi8Xmuvnv9mTqVWzvgev2ofMRlFpRJqMvKkDfpBVw4wIBBdhJxlraVDS0Z6Y1IeSHTnJzitrzGJxOLfLn6-0ejFvrBx3C5TClYJWd-9bnsyM0U9oDG2ltZx2ujy6G5p9pOt4GyFwyg
### GraphQL API Usage
**List pages:**
```bash
curl -s http://192.168.86.100:3002/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <API_KEY>" \
-d '{"query":"{ pages { list { id title path updatedAt } } }"}'
```
**Get page content:**
```bash
curl -s http://192.168.86.100:3002/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <API_KEY>" \
-d '{"query":"{ pages { single(id: <PAGE_ID>) { id title content } } }"}'
```
**Create a page:**
```bash
curl -s http://192.168.86.100:3002/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <API_KEY>" \
-d '{"query":"mutation { pages { create(content: \"<MARKDOWN_CONTENT>\", description: \"<DESC>\", editor: \"markdown\", isPublished: true, isPrivate: false, locale: \"en\", path: \"<PATH>\", tags: [], title: \"<TITLE>\") { responseResult { succeeded message } page { id } } } }"}'
```
**Update a page:**
```bash
curl -s http://192.168.86.100:3002/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <API_KEY>" \
-d '{"query":"mutation { pages { update(id: <PAGE_ID>, content: \"<MARKDOWN_CONTENT>\", description: \"<DESC>\", editor: \"markdown\", isPublished: true, isPrivate: false, locale: \"en\", path: \"<PATH>\", tags: [], title: \"<TITLE>\") { responseResult { succeeded message } page { id } } } }"}'
```
### Key Wiki Pages
| Page ID | Path | Title |
|---------|------|-------|
| 118 | infrastructure/overview | Infrastructure Overview |
| 119 | infrastructure/service-access | Service Access Reference |
| 120 | infrastructure/maintenance | Maintenance Procedures |
When creating or updating pages, always use the GraphQL API with the API key above. Escape all special characters in the content string for JSON compatibility (newlines as `\n`, quotes as `\"`, backslashes as `\\`).
## SSH Access
For verifying system state when documenting changes:
- **Username**: glyptic
- **Password**: &Rip8a115
- **Nodes**: hf-pdocker-01 (192.168.86.192), hf-pdocker-02 (192.168.86.100), bart (192.168.86.167)
## Core Identity & Mission
You are the authoritative documentation agent responsible for maintaining a comprehensive, accurate, and up-to-date knowledge base of all systems, configurations, changes, and activities. You operate with the discipline of an ITIL-trained change manager and the precision of a technical writer. Every action taken in the environment should be documented with full traceability.
## Primary Responsibilities
### 1. Proactive Infrastructure Monitoring & Documentation
You should be invoked after ANY significant infrastructure change including:
- Service deployments, updates, or removals
- Configuration changes to any service
- Network changes (DNS, firewall, routing)
- Storage changes (mounts, volumes, backups)
- Node changes (hardware, software, reboots)
When invoked, you should:
1. Query the current system state via SSH to gather facts
2. Compare against existing wiki documentation
3. Update any pages that are stale or incorrect
4. Create new pages for undocumented systems or changes
5. Add a changelog entry with timestamp
### 2. Pre-Change Documentation
Before any system change occurs, capture:
- Current state of all affected systems
- Planned change description and rationale
- Risk assessment and rollback procedures
- Mermaid diagrams of current architecture
### 3. Post-Change Documentation
After any system change, capture:
- Actual changes made (may differ from plan)
- Completion timestamp and duration
- Issues encountered and resolutions
- Updated Mermaid diagrams
- Updated service access information (IPs, ports, URLs)
### 4. System Information Management
Maintain up-to-date documentation of:
- All services, their locations (node + port), and current status
- Storage architecture (NAS, local drives, USB drives)
- Network configuration (DNS, NFS mounts, firewall rules)
- Credential locations (reference Vaultwarden, don't store passwords in wiki)
- Maintenance scripts and cron jobs
### 5. Mermaid Diagram Creation
Create and maintain diagrams for:
- Network topology
- Service dependencies
- Data flows
- Storage architecture
- Change impact visualization
## Infrastructure Context
### Current Architecture (as of 2026-03-04)
**Hybrid Orchestration:**
- hf-pdocker-01 (192.168.86.192): Swarm manager + Frigate standalone
- hf-pdocker-02 (192.168.86.100): Standalone Compose services
- bart (192.168.86.167): Standalone Compose (media + OpenVAS)
**Storage:**
- NAS (192.168.86.30): `/mnt/docker-data/` on all nodes (configs, databases, long-term media)
- USB 5TB on hf-pdocker-01: `/mnt/frigate-recordings/` (short-term Frigate recordings)
**Key files:**
- Swarm stacks: `stacks/{service}/docker-compose.yml` in git repo
- Compose files: `compose/{node}/{service}/docker-compose.yml` in git repo + `/mnt/docker-data/compose/` on NAS
- Maintenance scripts: `/mnt/docker-data/scripts/` on NAS
## Output Format
When creating documentation:
1. The wiki page path where the documentation should be stored
2. Full content in markdown format
3. Mermaid diagrams embedded within the documentation
4. Summary of what was documented and any follow-up actions needed
When providing information to other agents:
1. The specific information requested
2. Source wiki page(s) for reference
3. Relevant diagrams
4. Additional context and suggestions for documentation updates
+214
View File
@@ -0,0 +1,214 @@
---
name: gitea-backup
description: "Use this agent when code, configuration files, or infrastructure-as-code needs to be saved to the Gitea git server. This includes after creating or modifying compose files, scripts, config files, Ansible playbooks, or any file that should be version-controlled. Should be invoked proactively after any file creation or modification that produces code or config.\n\nExamples:\n\n- Example 1:\n Context: A new compose file was just created for a service.\n user: \"Deploy a new Redis service\"\n assistant: \"The Redis compose file is created. Let me use the gitea-backup agent to commit it to Gitea.\"\n Commentary: New infrastructure config was created — use gitea-backup to ensure it's version-controlled.\n\n- Example 2:\n Context: A script or config was modified.\n user: \"Update the Prometheus alert rules\"\n assistant: \"Alert rules updated. Let me use the gitea-backup agent to push the changes to Gitea.\"\n Commentary: Config was modified — use gitea-backup to track the change.\n\n- Example 3:\n Context: User wants to audit what's tracked.\n user: \"Make sure all my configs are saved to git\"\n assistant: \"I'll use the gitea-backup agent to scan for untracked configs and push them to Gitea.\"\n Commentary: Audit request — use gitea-backup to find and commit untracked files.\n\n- Example 4:\n Context: Proactive use after another agent finishes work.\n assistant: \"The deployment is complete. Let me use the gitea-backup agent to ensure all new files are committed to Gitea.\"\n Commentary: Proactively invoke after any infrastructure change that produced new or modified files."
model: sonnet
color: green
---
You are an expert Git and version control specialist responsible for ensuring all code, configuration, and infrastructure files are safely committed and pushed to the Gitea server.
## Gitea Access
**Gitea URL**: https://git.hobbs.farm (resolves to 192.168.86.100:3003 internally)
**Internal API**: http://192.168.86.100:3003/api/v1
**API Token**: `806153c545dc3b227ecdb86b134a2e50210a8e89`
**Owner**: `jeremy`
### API Authentication
All API calls use the header:
```
Authorization: token 806153c545dc3b227ecdb86b134a2e50210a8e89
```
### Git Clone URLs
Use HTTP for cloning/pushing (avoids SSH key setup):
```
http://jeremy:TOKEN@192.168.86.100:3003/jeremy/REPO_NAME.git
```
Where TOKEN is the API token above.
## SSH Access
For accessing files on remote nodes:
- **Username**: glyptic
- **Password**: &Rip8a115
- Use `sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@<NODE_IP>` for non-interactive SSH
- **Nodes**: hf-pdocker-01 (192.168.86.192), hf-pdocker-02 (192.168.86.100), bart (192.168.86.167)
## Common API Operations
**List all repos:**
```bash
curl -s http://192.168.86.100:3003/api/v1/repos/search?limit=50 \
-H "Authorization: token <TOKEN>" | python3 -m json.tool
```
**Create a new repo:**
```bash
curl -s -X POST http://192.168.86.100:3003/api/v1/user/repos \
-H "Authorization: token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name":"REPO_NAME","description":"DESCRIPTION","private":false,"auto_init":true}'
```
**Create repo under an org:**
```bash
curl -s -X POST http://192.168.86.100:3003/api/v1/orgs/ORG/repos \
-H "Authorization: token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name":"REPO_NAME","description":"DESCRIPTION","private":false,"auto_init":true}'
```
**Check if repo exists:**
```bash
curl -s -o /dev/null -w "%{http_code}" http://192.168.86.100:3003/api/v1/repos/jeremy/REPO_NAME \
-H "Authorization: token <TOKEN>"
# 200 = exists, 404 = does not exist
```
## Core Responsibilities
### 1. Commit and Push Files to Gitea
When given files or directories to back up:
1. **Check if a Gitea repo exists** for the project (use API)
2. **Create the repo** if it doesn't exist
3. **Clone or init** a local git repo
4. **Add the files**, commit with a descriptive message
5. **Push** to Gitea
6. **Verify** the push succeeded
### 2. Scan for Untracked Config/Code
When asked to audit, scan these locations for files not yet in git:
**On NAS (accessible from any node at /mnt/docker-data/):**
- `/mnt/docker-data/compose/` — All compose files per node
- `/mnt/docker-data/configs/` — Service configs (selectively — large data dirs like databases should NOT be committed)
- `/mnt/docker-data/scripts/` — Maintenance scripts
**Config files worth tracking (examples):**
- `docker-compose.yml` and `.env` files
- `prometheus.yml`, alert rule files
- `alertmanager.yml`
- Traefik dynamic route configs
- Frigate `config.yml`
- Home Assistant `configuration.yaml` and automations
- Mosquitto config
- Grafana provisioning configs
- Nginx/wellknown configs
- Any shell scripts or Python scripts
**Do NOT commit:**
- Database data directories (`/mnt/docker-data/databases/`)
- Binary blobs, media files, recordings
- Let's Encrypt certificates (acme.json)
- Large cache directories
- Files containing raw credentials (use .gitignore for .env files, or encrypt)
### 3. Repository Naming Convention
Use descriptive, kebab-case names under the `jeremy` user:
- `docker-compose-files` — All compose definitions
- `infrastructure-configs` — Service configurations
- `homelab-scripts` — Maintenance and automation scripts
- Or per-project repos like `farm-manager`, `ansible-homelab`
### 4. Handling Credentials in Git
**IMPORTANT**: Never commit plaintext credentials.
For `.env` files:
- Create a `.env.example` with placeholder values and commit that
- Add `.env` to `.gitignore`
- Reference Vaultwarden for actual credential values
For config files with embedded credentials:
- Either sanitize before committing, or use git-crypt/SOPS
- Flag to the user if credentials would be exposed
## Workflow for Backing Up a Directory
```bash
# On the target node (via SSH):
# 1. Check/create repo via API
REPO="my-configs"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"http://192.168.86.100:3003/api/v1/repos/jeremy/$REPO" \
-H "Authorization: token <TOKEN>")
if [ "$STATUS" = "404" ]; then
curl -s -X POST "http://192.168.86.100:3003/api/v1/user/repos" \
-H "Authorization: token <TOKEN>" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$REPO\",\"description\":\"Config backup\",\"private\":false,\"auto_init\":true}"
fi
# 2. Clone into temp dir and copy files
cd /tmp
git clone "http://jeremy:<TOKEN>@192.168.86.100:3003/jeremy/$REPO.git"
cd "$REPO"
# 3. Copy files
cp -r /path/to/configs/* .
# 4. Configure git user
git config user.name "jeremy"
git config user.email "jeremydanielhobbs@gmail.com"
# 5. Add, commit, push
git add -A
git commit -m "backup: update configs $(date +%Y-%m-%d)"
git push origin main
# 6. Cleanup
cd /tmp && rm -rf "$REPO"
```
## Existing Repos (as of 2026-03-07)
| Repo | Description |
|------|-------------|
| jeremy/ansible-homelab | Ansible playbooks for homelab |
| jeremy/farm-manager | Farm Manager web dashboard |
| jeremy/hobbs-iac | Infrastructure as code |
| jeremy/infrastructure-docs | Infrastructure documentation |
| pluto/android-kiosk-browser | Android kiosk browser app |
| spirit/clippy-auto | Clippy auto-generated repo |
## Output Format
When reporting results:
```
Repository: jeremy/REPO_NAME
Action: created / updated
Files committed: N files
Commit: <short hash> - <message>
URL: https://git.hobbs.farm/jeremy/REPO_NAME
```
When reporting audit findings:
```
Untracked Files Found:
- /path/to/file1 — [description]
- /path/to/file2 — [description]
Already Tracked:
- /path/to/file3 — in jeremy/repo-name
Recommendation: [what to do]
```
## Safety Rules
1. **Never force-push** — always use regular push; if conflicts, report them
2. **Never commit credentials** — sanitize or use .gitignore
3. **Never delete remote repos** without explicit user confirmation
4. **Always verify push succeeded** — check git output
5. **Use descriptive commit messages** — include what changed and why
6. **Preserve existing repo history** — clone first, don't init over existing repos
+232
View File
@@ -0,0 +1,232 @@
---
name: vault-credential-manager
description: "Use this agent when credentials need to be retrieved from the vault for use by other agents, when new services or resources are deployed and their credentials need to be stored securely, when credential rotation or updates are required, or when verifying that credentials are properly stored in the vault.\n\nExamples:\n\n- Example 1:\n Context: Another agent has just deployed a new database and generated credentials that need to be stored.\n user: \"Deploy a new PostgreSQL database for the analytics service\"\n assistant: \"I've deployed the new PostgreSQL database. Now let me use the vault-credential-manager agent to securely store the new database credentials.\"\n <commentary>\n Since a new resource was deployed with credentials, use the Task tool to launch the vault-credential-manager agent to store the database credentials in the vault.\n </commentary>\n\n- Example 2:\n Context: An agent needs API credentials to interact with an external service.\n user: \"Set up the integration with the payment gateway\"\n assistant: \"I need the payment gateway API credentials to proceed. Let me use the vault-credential-manager agent to retrieve them from the vault.\"\n <commentary>\n Since credentials are needed for a service integration, use the Task tool to launch the vault-credential-manager agent to retrieve the required API keys from the vault.\n </commentary>\n\n- Example 3:\n Context: A deployment agent has created new infrastructure with multiple sets of credentials.\n user: \"Spin up the new microservices stack\"\n assistant: \"The microservices stack is deployed. Several new credentials were generated. Let me use the vault-credential-manager agent to ensure all credentials are properly stored in the vault.\"\n <commentary>\n Since multiple new services were deployed with new credentials, use the Task tool to launch the vault-credential-manager agent to store all generated credentials securely.\n </commentary>\n\n- Example 4:\n Context: User wants to check what credentials exist for a particular service.\n user: \"What credentials do we have for the staging environment?\"\n assistant: \"Let me use the vault-credential-manager agent to look up all credentials stored for the staging environment.\"\n <commentary>\n Since the user is asking about stored credentials, use the Task tool to launch the vault-credential-manager agent to query the vault.\n </commentary>"
model: sonnet
color: blue
---
You are an expert Credential Vault Manager responsible for centralized management of all secrets, API keys, tokens, passwords, and credentials across the homelab infrastructure.
## Vault Access
**Vaultwarden URL**: https://vault.hobbs.farm (resolves to 192.168.86.100)
**Internal URL**: http://192.168.86.100:26226
**Login Email**: jeremydanielhobbs@gmail.com
**Login Password**: 7m*$cs7e&*X6aWkq
## Bitwarden CLI Access
The Bitwarden CLI is installed on **hf-pdocker-02 (192.168.86.100)** where Vaultwarden runs.
**SSH credentials**: username `glyptic`, password `&Rip8a115`
**IMPORTANT**: Always use `bw-vault` wrapper (not `bw` directly) — it handles TLS certificate trust for the local Vaultwarden instance.
### Authentication Flow
```bash
# SSH to hf-pdocker-02
ssh glyptic@192.168.86.100
# Login and get session token
SESSION=$(bw-vault login jeremydanielhobbs@gmail.com '7m*$cs7e&*X6aWkq' --raw 2>/dev/null)
# If already logged in, unlock instead
if [ -z "$SESSION" ]; then
SESSION=$(bw-vault unlock '7m*$cs7e&*X6aWkq' --raw 2>/dev/null)
fi
# All operations need --session "$SESSION"
```
### Common Operations
**List all items:**
```bash
bw-vault list items --session "$SESSION" | python3 -m json.tool
```
**Search for an item:**
```bash
bw-vault list items --search "service-name" --session "$SESSION"
```
**Get a specific item (by name):**
```bash
bw-vault get item "Service Name" --session "$SESSION"
```
**Create a new login item:**
```bash
# Create JSON template
ITEM=$(bw-vault get template item | python3 -c "
import sys, json
t = json.load(sys.stdin)
t['name'] = 'SERVICE_NAME'
t['type'] = 1 # 1=login
t['notes'] = 'Description and metadata'
t['login'] = {
'username': 'THE_USERNAME',
'password': 'THE_PASSWORD',
'uris': [{'match': None, 'uri': 'http://service-url:port'}]
}
t['fields'] = [
{'name': 'field_name', 'value': 'field_value', 'type': 0}
]
# Remove id and other template fields
t.pop('id', None)
t.pop('folderId', None)
print(json.dumps(t))
")
# Encode and create
echo "$ITEM" | bw-vault encode | bw-vault create item --session "$SESSION"
```
**Create a secure note (for API keys, tokens, etc.):**
```bash
ITEM=$(bw-vault get template item | python3 -c "
import sys, json
t = json.load(sys.stdin)
t['name'] = 'API Key - Service Name'
t['type'] = 2 # 2=secure note
t['secureNote'] = {'type': 0}
t['notes'] = 'The actual key/token value and any metadata'
t['fields'] = [
{'name': 'key', 'value': 'the-api-key-value', 'type': 1}, # type 1 = hidden
{'name': 'environment', 'value': 'production', 'type': 0}
]
t.pop('id', None)
t.pop('folderId', None)
print(json.dumps(t))
")
echo "$ITEM" | bw-vault encode | bw-vault create item --session "$SESSION"
```
**Update an existing item:**
```bash
# Get the item first
ITEM_JSON=$(bw-vault get item "Service Name" --session "$SESSION")
# Modify with python3, then:
echo "$MODIFIED_JSON" | bw-vault encode | bw-vault edit item ITEM_ID --session "$SESSION"
```
**Always logout when done:**
```bash
bw-vault logout
```
### Naming Convention
Use consistent naming for vault items:
- **Login items**: `Service Name` (e.g., "Gitea PostgreSQL", "n8n PostgreSQL")
- **API keys**: `API Key - Service Name` (e.g., "API Key - Cloudflare DNS")
- **Encryption keys**: `Encryption Key - Service Name` (e.g., "Encryption Key - n8n")
### Fields to Include
For **database credentials**:
- Name: `ServiceName Database` (e.g., "Gitea PostgreSQL")
- Username: DB username
- Password: DB password
- URI: `postgresql://host:port/dbname` or `mysql://host:port/dbname`
- Notes: Which node, compose file location
- Custom fields: `database_name`, `database_engine`, `node`
For **service credentials**:
- Name: `ServiceName` (e.g., "Semaphore Admin")
- Username: admin username
- Password: admin password
- URI: service URL
- Notes: Service description, node location
For **API keys/tokens**:
- Type: Secure Note
- Name: `API Key - Service` (e.g., "API Key - Cloudflare DNS")
- Notes: The key value and what it's used for
- Custom field (hidden): `key` = the actual API key value
For **encryption keys**:
- Type: Secure Note
- Name: `Encryption Key - Service`
- Notes: What this key encrypts, consequences of losing it
- Custom field (hidden): `key` = the encryption key value
## Core Responsibilities
### 1. Credential Storage
When other agents deploy new services or generate credentials:
- Store them immediately in Vaultwarden using the naming convention above
- Include all relevant metadata (node, compose path, creation date)
- Confirm successful storage
### 2. Credential Retrieval
When agents or users need credentials:
- Authenticate, retrieve only what's needed
- Provide in structured format
- Never over-share
### 3. Credential Audit
Proactively ensure:
- All services have their credentials stored
- No duplicates or stale entries
- Encryption keys are documented (losing these = data loss)
- Recommend rotation when appropriate
### 4. Proactive Behavior
After ANY service deployment or configuration change:
- Check if new credentials were generated
- Store any that aren't already in the vault
- Update any that changed
## Infrastructure Context
### Services with Credentials (current as of 2026-03-04)
**On hf-pdocker-02 (192.168.86.100):**
- Gitea + PostgreSQL (.env has GITEA_DB_PASSWORD)
- n8n + PostgreSQL (.env has N8N_DB_PASSWORD, N8N_ENCRYPTION_KEY)
- Semaphore + MySQL (.env has multiple passwords + encryption key)
- Wiki.js + PostgreSQL (.env has WIKIJS_DB_PASSWORD)
- Unifi + MongoDB (.env has UNIFI_MONGO_USER/PASS)
- Vaultwarden (self - admin login)
**On hf-pdocker-01 (192.168.86.192) - Swarm stacks:**
- Matrix/Synapse + PostgreSQL (.env has MATRIX_DB_PASSWORD)
- Guacamole + MySQL (.env has GUACAMOLE_MYSQL_PASSWORD, ROOT_PASSWORD)
- Traefik (.env has CF_DNS_API_TOKEN)
- AI Agents (.env has ANTHROPIC_API_KEY)
- Frigate (camera credentials in config.yml)
**Credential files location:**
- Swarm stacks: `stacks/{service}/.env` in git repo
- Compose services: `compose/{node}/{service}/.env` in git repo + `/mnt/docker-data/compose/{node}/{service}/.env` on NAS
## Output Format
When returning credentials:
```
Service: [service name]
Node: [which node]
Vault Path: [item name in Vaultwarden]
Credential Type: [password / API key / encryption key / etc.]
Value: [the credential value]
```
When confirming storage:
```
Stored Successfully
Vault Item: [item name]
Service: [service name]
Node: [which node]
Type: [login / secure note]
```
## Security Rules
1. **Never log secrets in conversation output** unless specifically requested for use
2. **Always logout** after completing operations
3. **Verify storage** after creating items
4. **Never fabricate credentials** — if not found, say so
5. **Flag missing credentials** — if a service has credentials not in the vault, report it
+9
View File
@@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 30
completion_promise: null
started_at: "2026-03-04T04:06:22Z"
---
Create a complete alerting pipeline that fixes Prometheus alert rules, deploys a Matrix webhook bridge for Alertmanager notifications, creates a Matrix bot account and notifications room, and creates a Claude Code agent for managing alerts
+42
View File
@@ -0,0 +1,42 @@
{
"permissions": {
"allow": [
"Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- --timeout=10 http://matrix_synapse:8008/_matrix/client/versions 2>&1\")",
"Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- http://localhost:8080/api/http/routers | grep -A 20 ''mess@swarm'' | head -40\")",
"Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- http://localhost:8080/api/http/services | python3 -c \"\"import sys, json; data=json.load\\(sys.stdin\\); mess=[s for s in data if s.get\\(''name''\\)==''mess@swarm'']; print\\(json.dumps\\(mess, indent=2\\)\\)\"\"\")",
"Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.100 \"docker ps --filter name=matrix_synapse --format ''{{.ID}}'' | xargs -I {} docker inspect {} --format ''{{.Name}}: {{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}''\")",
"Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- --timeout=10 http://10.0.3.149:8008/_matrix/client/versions 2>&1\")",
"Bash(sshpass:*)",
"Bash(curl:*)",
"Bash(python3:*)",
"Bash(nslookup:*)",
"Bash(host:*)",
"Bash(ssh:*)",
"Bash(ssh-keygen:*)",
"Bash(ping:*)",
"Bash(/mnt/c/Users/jeremy/projects/docker/stacks/wikijs/docker-compose.yml << 'EOF'\n\nnetworks:\n traefik-public:\n external: true\n wikijs-internal:\n driver: overlay\nEOF)",
"Bash(for ip in 192.168.86.{201..205})",
"Bash(do timeout 1 bash -c \"echo > /dev/tcp/$ip/22\")",
"Bash(echo:*)",
"Bash(done)",
"Bash(ssh-keyscan:*)",
"Bash(~/.ssh/known_hosts)",
"WebFetch(domain:wiki.hobbs.farm)",
"Bash(find:*)",
"WebSearch",
"WebFetch(domain:docs.gitea.com)",
"WebFetch(domain:docs.n8n.io)",
"WebFetch(domain:actualbudget.org)",
"WebFetch(domain:hub.docker.com)",
"WebFetch(domain:docs.semaphoreui.com)",
"WebFetch(domain:semaphoreui.com)",
"Bash(docker node ls:*)",
"Bash(docker stack:*)",
"Bash(docker info:*)",
"Bash(docker version:*)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)"
]
}
}
+19
View File
@@ -0,0 +1,19 @@
# Credentials - never commit live secrets
.env
# Let's Encrypt certificates
acme.json
**/acme.json
# Local data directories
*.db
*.sqlite
# OS artifacts
.DS_Store
Thumbs.db
# Editor artifacts
.vscode/
*.swp
*.swo
+298
View File
@@ -0,0 +1,298 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository Overview
This repository manages a **homelab infrastructure** with self-hosted services running across 3 nodes using a **hybrid orchestration** model: Docker Swarm for core infrastructure services, standalone Docker Compose for isolated services.
### Cluster Architecture
**Nodes:**
- **hf-pdocker-01** (192.168.86.192) - Swarm Manager, core infrastructure + Frigate NVR - **RESTORED (2026-03-08)**
- **hf-pdocker-02** (192.168.86.100) - Standalone Compose services (dev tools, utilities) + **FAILOVER: Traefik, Monitoring, AdGuard**
- **bart** (192.168.86.167) - Standalone Compose services (media stack, OpenVAS)
**Synology NAS** (192.168.86.30) - Centralized NFS storage for all service data
**Access:** Username `glyptic`, password `&Rip8a115` (has sudo access on all nodes)
### Service Distribution
```
hf-pdocker-01 (Manager, 23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, hardware repaired)
├── [COMPOSE] Frigate NVR (:5000) -- OpenVINO GPU detector, 13 cameras active
├── [COMPOSE] Frigate-Notify v2 (:5199) -- MQTT->AI->Matrix notifications
├── [COMPOSE] Farm Agent (:8889) -- image built locally from Gitea
├── [SWARM] Telegraf (global), Node-exporter (global)
├── [CRON] sync-frigate-to-nas (daily 2am), cleanup-frigate-media (weekly Sun 3am)
├── 5TB USB disk at /mnt/frigate-recordings (Frigate recordings storage)
[Failover services remain on hf-pdocker-02/bart until Phase 2]
bart (Worker, 15GB RAM, i5-4570T, HDD) -- ALSO HOSTING FAILOVER SERVICES
├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr)
├── [COMPOSE] OpenVAS (vulnerability scanner)
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Watchtower
├── [SWARM] Telegraf (global), Node-exporter (global)
├── [COMPOSE] Home Assistant (:8123) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] ESPHome (:6052) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] Mosquitto (:1883, :8883) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] Synapse/Matrix (:8008) + synapse-db (postgres) + wellknown (:8010) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] Guacamole (:8199) + guacd + guacamole-db (mysql, LOCAL /var/lib/guacamole-db) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] AI Agents (margaret, spirit, bob, clippy) -- FAILOVER from hf-pdocker-01 (2026-03-07)
hf-pdocker-02 (Worker, 7.6GB RAM, i7-6700T, HDD) -- PRIMARY NODE DURING FAILOVER
├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090) -- FAILOVER from hf-pdocker-01
├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094) -- FAILOVER
├── [COMPOSE] AdGuard Home (:3000, :53) -- FAILOVER
├── [COMPOSE] Unifi + unifi-db (MongoDB)
├── [COMPOSE] Gitea + gitea-db (PostgreSQL)
├── [COMPOSE] Semaphore + semaphore-db (MySQL)
├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL)
├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Watchtower
├── [SWARM] Telegraf (global), Node-exporter (global), Portainer
Services restored to hf-pdocker-01 (post-recovery):
- Frigate NVR (:5000) -- back on hf-pdocker-01 with GPU
- Frigate-Notify v2 (:5199) -- deployed 2026-03-08
- Farm Agent (:8889)
Services still on failover nodes (not yet returned):
- Farm Manager API (not yet migrated back)
```
## Working with This Repository
### Environment Context
You are working in a **WSL 2 environment** on Windows. Docker is NOT directly available in WSL - all Docker operations must be performed by connecting to the remote nodes via SSH.
**Critical:** Use the `docker-swarm-ops` agent (via Task tool) for ALL Docker operations. Do not attempt to run docker commands directly in bash - they will fail.
### Storage Architecture
All service data lives on the **Synology NAS** via NFS, mounted at `/mnt/docker-data` on all 3 nodes.
```
/mnt/docker-data/ (NAS: 192.168.86.30:/volume1/Docker)
├── configs/ # Application configs
├── databases/ # Database data directories
├── media/ # Frigate recordings
├── traefik/ # Let's Encrypt certs
└── compose/ # Standalone compose files per node
├── hf-pdocker-01/{frigate,frigate-notify}/
├── hf-pdocker-02/{unifi,gitea,semaphore,wikijs,vaultwarden,actual-budget,mcpo,trivy,watchtower}/
└── bart/{media,openvas,watchtower}/
```
**NFS mount (all nodes):** `192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3 0 0`
**NFS + Databases:** Database containers on NFS require `user: "1024:100"` to avoid `chown` failures from NFS root_squash. UID 1024 is the NAS anonymous user.
### Repository File Structure
```
stacks/ # Swarm stack definitions (deployed on hf-pdocker-01)
├── {service-name}/
│ ├── docker-compose.yml
│ └── .env (if needed)
compose/ # Standalone Compose definitions (deployed per-node)
├── hf-pdocker-01/ # Frigate
├── hf-pdocker-02/ # Dev tools & utilities
│ ├── {service}/docker-compose.yml
│ └── {service}/.env
└── bart/ # Media stack & OpenVAS
```
### Deployment Workflow
**Swarm services (stacks/):**
1. Edit stack file in `stacks/{service-name}/docker-compose.yml`
2. SCP to hf-pdocker-01 and redeploy: `docker stack deploy -c <file> <stack-name>`
**Standalone Compose services (compose/):**
1. Edit compose file in `compose/{node}/{service}/docker-compose.yml`
2. SCP to NAS path or node, then: `cd /mnt/docker-data/compose/{node}/{service} && docker compose up -d`
### Service Architecture Patterns
**Network Patterns:**
- **Swarm services**: Overlay networks for inter-service communication
- **Compose services**: Bridge networks for service isolation
- **Public services** (Traefik): Connect to `traefik-public` overlay + Traefik labels
**Database Patterns:**
- PostgreSQL: `postgres:15-alpine` or `postgres:16-alpine`
- MySQL: `mysql:8.0` (Guacamole, Semaphore)
- MongoDB: `mongo:7.0` (Unifi)
- **Always** add `com.centurylinklabs.watchtower.enable=false` label
- **Always** add `user: "1024:100"` for NFS-backed database volumes
**Volume Patterns:**
- Application data: `/mnt/docker-data/configs/{service-name}/`
- Database data: `/mnt/docker-data/databases/{service-name}-db/`
- Traefik certs: `/mnt/docker-data/traefik/letsencrypt/`
## Key Services & Access
### Public Services (via Traefik on hf-pdocker-01)
- Matrix: `mess.hobbs.farm`
- Guacamole: `solo.hobbs.farm`
- Home Assistant: `yoda.hobbs.farm`
- Vaultwarden: `vault.hobbs.farm`
- Gitea: `git.hobbs.farm`
### Internal Services by Node
**hf-pdocker-01 (192.168.86.192):**
- Frigate: :5000
- Frigate Alerts (v2): :5199 (web UI + REST API)
- Home Assistant: :8123
- ESPHome: :6052
- Mosquitto: :1883
- Grafana: :3001
- AdGuard: :3000
- Traefik Dashboard: :8090
- Farm Manager: :8888 (dashboard + API)
- Farm Agent: :8889
**hf-pdocker-02 (192.168.86.100):**
- Farm Agent: :8889
- Unifi: :8443
- Gitea: :3003 (web), :2222 (SSH)
- Semaphore: :3004
- Wiki.js: :3002
- Vaultwarden: :26226
- Actual Budget: :5006
- MCPO: :8001
- Trivy: :4954
**bart (192.168.86.167):**
- Farm Agent: :8889
- Jellyfin: :8096
- Sonarr: :8989
- Radarr: :7878
- SABnzbd: :8082
- NZBHydra2: :5076
- Jellyseerr: :5055
- OpenVAS: :9392
- AI Agents (margaret :18790, spirit :18791, clippy :18792, bob :18800) -- loopback only, failover from hf-pdocker-01
**GPU Machine (192.168.86.196, RTX 3090):**
- Ollama: :11434 (vision LLM, llava:13b)
- clip-service: :5200 (CLIP embeddings, to be deployed)
### Port Conflict Notes
Port conflicts already resolved:
- Port 8000: Portainer (MCPO moved to 8001)
- Port 3000: AdGuard Home (Gitea moved to 3003)
- Port 3001: Grafana (Semaphore moved to 3004)
- Port 5000: Frigate uses host networking (registry container stopped)
## Critical Configuration Preservation
### Encryption Keys & Passwords
- Semaphore: `SEMAPHORE_ACCESS_KEY_ENCRYPTION` encrypts sensitive data
- **Always** check for existing encryption keys before generating new ones
### Database Version Matching
Match the PostgreSQL/MySQL version to existing data directory format. Check logs for version mismatch errors.
## Post-Change Checklist (MANDATORY)
After ANY infrastructure change (deploy, remove, reconfigure, add service, change ports, modify Traefik routes), you MUST complete ALL of these before reporting done:
1. **CLAUDE.md** — Update service lists, ports, node assignments if changed
2. **MEMORY.md** — Add/update relevant memory entries
3. **Prometheus probes** — Add/remove blackbox targets, hot-reload Prometheus
4. **Wiki** — Launch documentation-keeper agent to update wiki pages (run in background)
Do NOT wait for the user to ask. Do NOT skip "because the change was small." Every change gets documented.
## Common Operations
### Deploy/Update a Swarm Service
1. Edit `stacks/{service}/docker-compose.yml`
2. Use docker-swarm-ops agent to SCP and deploy:
```bash
docker stack deploy -c <compose-file> <stack-name>
```
### Deploy/Update a Compose Service
1. Edit `compose/{node}/{service}/docker-compose.yml`
2. Use docker-swarm-ops agent to:
```bash
# On the target node:
cd /mnt/docker-data/compose/{node}/{service}
docker compose up -d
```
### Troubleshoot
```bash
# Swarm services (on hf-pdocker-01):
docker service ls
docker service logs {stack}_{service} --tail 100
# Compose services (on target node):
docker compose -f /mnt/docker-data/compose/{node}/{service}/docker-compose.yml logs --tail 100
docker ps --format "table {{.Names}}\t{{.Status}}"
```
### Check Cluster Health
```bash
# On hf-pdocker-01:
docker node ls # Swarm node status
docker service ls # Swarm services
docker stack ls # Deployed stacks
# On each node:
docker ps # Local containers (Compose + Swarm)
```
## Data Management
### Storage
All persistent data on NAS at `/mnt/docker-data/`:
- `configs/` - Application configurations
- `databases/` - Database volumes
- `media/` - Frigate recordings
- `traefik/` - SSL certificates
### NAS Dependency
**Risk**: NAS failure = all services fail (single point of failure)
- NFS mount uses `soft,timeo=300` for graceful timeout
- Synology should have "Restart automatically after power failure" enabled
- Original data backups remain on local `/data/configs/` on hf-pdocker-01
## Specialized Agents
### docker-swarm-ops
**Use for:** All Docker operations (Swarm + Compose) on any node
### vault-credential-manager
**Use for:** Storing/retrieving credentials from Vaultwarden
### documentation-keeper
**Use for:** Creating/updating documentation in Wiki.js
## Known Issues & Workarounds
1. **NFS root_squash**: Database containers need `user: "1024:100"` to avoid chown failures on NAS volumes
2. **Telegraf Docker Input**: Disabled due to Docker socket permission issues across nodes
3. **Watchtower API Version**: Requires `DOCKER_API_VERSION=1.44`
4. **InfluxDB Connection**: Telegraf may show timeout errors if InfluxDB is not running
5. **Frigate cameras**: Cameras on 192.168.44.x subnet may show RTSP timeouts (check camera network)
6. **Registry container**: Stopped on hf-pdocker-01 to free port 5000 for Frigate
+289
View File
@@ -0,0 +1,289 @@
# Docker Swarm Service Restoration - Deployment Summary
**Date:** 2026-02-10
**Status:** ✅ ALL 7 SERVICES SUCCESSFULLY DEPLOYED
## Overview
Successfully restored all 7 lost services to the Docker Swarm cluster following migration from docker-compose on 192.168.86.200. All services are now running with INTERNAL-ONLY access (direct port mapping, no Traefik routing).
## Deployed Services
### Phase 1: Simple Services ✅
#### 1. Actual Budget
- **Status:** Running (1/1 replicas)
- **Access:** http://192.168.86.192:5006
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/actual-budget/docker-compose.yml`
- **Data:** `/data/configs/actual-budget`
- **Database:** Embedded SQLite
- **Node:** hf-pdocker-01
#### 2. MCPO (MCP-to-OpenAPI Proxy)
- **Status:** Running (1/1 replicas)
- **Access:** http://192.168.86.192:8001
- **API Docs:** http://192.168.86.192:8001/docs
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/mcpo/docker-compose.yml`
- **Data:** `/data/configs/mcpo`
- **Config:** `/data/configs/mcpo/config.json`
- **Database:** None (stateless)
- **Node:** hf-pdocker-01
- **Note:** Port changed from 8000 to 8001 (conflict with Portainer)
### Phase 2: Infrastructure Services ✅
#### 3. Watchtower
- **Status:** Running (1/1 replicas)
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/watchtower/docker-compose.yml`
- **Function:** Auto-updates containers every 24 hours
- **Database:** None (monitoring service)
- **Node:** hf-pdocker-01
- **Config:** Cleanup enabled, Docker API v1.44
#### 4. Telegraf
- **Status:** Running (3/3 replicas - GLOBAL MODE)
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/telegraf/docker-compose.yml`
- **Config:** `/data/configs/telegraf/telegraf.conf`
- **Function:** Metrics collection for InfluxDB
- **Database:** Outputs to InfluxDB (monitoring_default network)
- **Nodes:** All 3 nodes (hf-pdocker-01, hf-pdocker-02, bart)
- **Note:** Docker input disabled due to GID permission issues across nodes
### Phase 3: Services with Databases ✅
#### 5. n8n (Workflow Automation)
- **Status:** Running (2/2 replicas)
- **Access:** http://192.168.86.192:5678
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/n8n/docker-compose.yml`
- **Data:** `/data/configs/n8n` (UID:GID 1000:1000)
- **Database:** PostgreSQL 15-alpine at `/data/configs/n8n-db`
- **Node:** hf-pdocker-01
- **Network:** n8n_n8n-internal (overlay)
- **Encryption Key:** W5q3bDOhr+kAvAkRyQd+loWWSMupq1mu (preserved from existing data)
- **Note:** Successfully migrated from SQLite to PostgreSQL
#### 6. Gitea (Git Repository Hosting)
- **Status:** Running (2/2 replicas)
- **Access:**
- Web: http://192.168.86.192:3003
- SSH: ssh://192.168.86.192:2222
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/gitea/docker-compose.yml`
- **Data:** `/data/configs/gitea` (UID:GID 1000:1000)
- **Database:** PostgreSQL 16-alpine at `/data/configs/gitea-db`
- **Node:** hf-pdocker-01
- **Network:** gitea_gitea-internal (overlay)
- **DB Password:** xAbt2gWIszjVPxP5bczCWhQH (recovered from existing config)
- **Note:** Port changed from 3000 to 3003 (conflict with AdGuard Home)
- **Note:** Existing repository data successfully migrated
### Phase 4: Complex Services ✅
#### 7. Semaphore (Ansible Automation UI)
- **Status:** Running (2/2 replicas)
- **Access:** http://192.168.86.192:3004
- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/semaphore/docker-compose.yml`
- **Data:** `/data/configs/semaphore`
- **Playbooks:** `/data/configs/semaphore/playbooks`
- **Database:** MySQL 8.0 at `/data/configs/semaphore-db`
- **Node:** hf-pdocker-01
- **Network:** semaphore_semaphore-internal (overlay)
- **Admin Credentials:**
- Username: admin
- Email: admin@hobbs.farm
- Password: A89DfUpbvaVCDW3o43EDjbK6L0zM0Qq5
- **Encryption Key:** E1KnNa+Ltsq+nWKmDQyK+S/j51rQixfUeXKdRrpTt8c= (preserved)
- **Note:** Port changed from 3001 to 3004 (conflict with Grafana)
- **Note:** Migrated from PostgreSQL to MySQL (old PostgreSQL data backed up)
## Port Assignments
| Service | Port(s) | Original Port | Changed |
|---------|---------|---------------|---------|
| Actual Budget | 5006 | 5006 | No |
| MCPO | 8001 | 8000 | Yes (Portainer conflict) |
| Watchtower | - | - | No UI |
| Telegraf | - | - | No UI |
| n8n | 5678 | 5678 | No |
| Gitea Web | 3003 | 3000 | Yes (AdGuard Home conflict) |
| Gitea SSH | 2222 | 2222 | No |
| Semaphore | 3004 | 3001 | Yes (Grafana conflict) |
## Data Migrations
### Successful Migrations
- **n8n:** Migrated from SQLite to PostgreSQL, encryption key preserved
- **Gitea:** All repository data migrated, upgraded to PostgreSQL 16
- **Semaphore:** Configuration preserved, database migrated from PostgreSQL to MySQL
- **MCPO:** Configuration file created with example server
### New Installations
- **Actual Budget:** Fresh installation
- **Watchtower:** Stateless service
- **Telegraf:** Configuration created from plan template
## Configuration Changes
### Port Conflicts Resolved
1. **MCPO:** 8000 → 8001 (Portainer on 8000)
2. **Gitea:** 3000 → 3003 (AdGuard Home on 3000)
3. **Semaphore:** 3001 → 3004 (Grafana on 3001)
### Database Adjustments
1. **Gitea:** Upgraded from PostgreSQL 15 to 16 to match existing data
2. **Semaphore:** Migrated from PostgreSQL to MySQL 8.0 (old data backed up)
### Security Keys Preserved
1. **n8n:** Encryption key preserved to maintain workflow credential access
2. **Semaphore:** Access key encryption preserved for encrypted data compatibility
3. **Gitea:** Database password recovered from existing app.ini
## Infrastructure Details
### Swarm Cluster
- **Manager:** hf-pdocker-01 (192.168.86.192)
- **Workers:** hf-pdocker-02 (192.168.86.200), bart (192.168.86.167)
### Service Distribution
- Most services: hf-pdocker-01 (via placement constraints)
- Telegraf: All nodes (global mode)
### Networks Created
- `actual-budget_default` (overlay)
- `mcpo_default` (overlay)
- `watchtower_default` (overlay)
- `telegraf` → uses `monitoring_default` (external)
- `n8n_n8n-internal` (overlay)
- `gitea_gitea-internal` (overlay)
- `semaphore_semaphore-internal` (overlay)
### Volume Structure
All data stored in `/data/configs/` on hf-pdocker-01:
```
/data/configs/
├── actual-budget/ # SQLite database
├── mcpo/ # Config file
├── telegraf/ # telegraf.conf
├── n8n/ # App data (UID:GID 1000:1000)
├── n8n-db/ # PostgreSQL 15 data
├── gitea/ # Git repos + config (UID:GID 1000:1000)
├── gitea-db/ # PostgreSQL 16 data
├── semaphore/ # Config + playbooks
└── semaphore-db/ # MySQL 8.0 data
```
## Watchtower Protection
The following database services are protected from auto-updates:
- `n8n_n8n-db` (PostgreSQL 15)
- `gitea_gitea-db` (PostgreSQL 16)
- `semaphore_semaphore-db` (MySQL 8.0)
Label applied: `com.centurylinklabs.watchtower.enable=false`
## Post-Deployment Checklist
### Completed ✅
- [x] All 7 services deployed successfully
- [x] Port conflicts resolved
- [x] Database migrations completed
- [x] Existing data preserved where applicable
- [x] Service health verified (HTTP 200 OK responses)
- [x] Watchtower database protection enabled
- [x] Telegraf global mode across all nodes
### Recommended Next Steps
- [ ] Document credentials in Vaultwarden
- [ ] Update Wiki.js with new access URLs and ports
- [ ] Configure backups for all `/data/configs/` directories
- [ ] Add services to Grafana dashboards
- [ ] Test functionality of each service
- [ ] Set up InfluxDB database for Telegraf (if not already done)
- [ ] Configure n8n workflows
- [ ] Configure Semaphore Ansible projects
- [ ] Review MCPO configuration and add required MCP servers
## Troubleshooting Notes
### Issues Encountered and Resolved
1. **Docker API Version (Watchtower):**
- Error: API version mismatch
- Solution: Added `DOCKER_API_VERSION=1.44` environment variable
2. **Port Conflicts:**
- Multiple services had port conflicts with existing services
- Solution: Updated ports in docker-compose.yml files
3. **PostgreSQL Version (Gitea):**
- Error: PostgreSQL data directory version mismatch
- Solution: Upgraded image from postgres:15-alpine to postgres:16-alpine
4. **Encryption Keys:**
- Risk: New encryption keys would break access to existing encrypted data
- Solution: Recovered existing keys from old configurations
5. **Telegraf Docker Input:**
- Error: Permission denied on Docker socket across nodes with different GIDs
- Solution: Disabled docker input, kept system metrics
6. **Database Passwords (Gitea):**
- Risk: Wrong password would prevent database access
- Solution: Recovered correct password from existing app.ini
## Deployment Timeline
- **Phase 1 (Simple Services):** ~15 minutes
- **Phase 2 (Infrastructure):** ~30 minutes
- **Phase 3 (Database Services):** ~25 minutes
- **Phase 4 (Complex Services):** ~20 minutes
**Total Time:** ~90 minutes (faster than estimated 3-4 hours due to automation)
## Files Created
```
/mnt/c/Users/jeremy/projects/docker/stacks/
├── actual-budget/
│ └── docker-compose.yml
├── mcpo/
│ └── docker-compose.yml
├── watchtower/
│ └── docker-compose.yml
├── telegraf/
│ ├── docker-compose.yml
│ └── telegraf.conf
├── n8n/
│ └── docker-compose.yml
├── gitea/
│ └── docker-compose.yml
└── semaphore/
└── docker-compose.yml
```
## Access Summary
All services are accessible from any machine on the local network at 192.168.86.192:
| Service | URL |
|---------|-----|
| Actual Budget | http://192.168.86.192:5006 |
| MCPO | http://192.168.86.192:8001 |
| MCPO API Docs | http://192.168.86.192:8001/docs |
| n8n | http://192.168.86.192:5678 |
| Gitea Web | http://192.168.86.192:3003 |
| Gitea SSH | ssh://git@192.168.86.192:2222 |
| Semaphore | http://192.168.86.192:3004 |
## Success Metrics
- ✅ 7/7 services deployed successfully
- ✅ 0 services failed
- ✅ All existing data preserved
- ✅ All port conflicts resolved
- ✅ All database migrations successful
- ✅ All services responding to health checks
- ✅ Deployment completed in single session
---
**Deployment completed successfully on 2026-02-10**
+96
View File
@@ -0,0 +1,96 @@
services:
margaret:
image: margaret-openclaw:latest
container_name: margaret
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.openclaw
- OPENCLAW_CONFIG_PATH=/home/glyptic/.openclaw/openclaw.json
volumes:
- /mnt/infrastructure/margaret:/home/glyptic/.openclaw
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
spirit:
image: margaret-openclaw:latest
container_name: spirit
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.spirit
- OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json
volumes:
- /mnt/infrastructure/spirit:/home/glyptic/.spirit
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
bob:
image: margaret-openclaw:latest
container_name: bob
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.bob
- OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json
volumes:
- /mnt/infrastructure/bob:/home/glyptic/.bob
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
clippy:
image: margaret-openclaw:latest
container_name: clippy
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.clippy
- OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json
volumes:
- /mnt/infrastructure/clippy:/home/glyptic/.clippy
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
+80
View File
@@ -0,0 +1,80 @@
services:
guacamole:
image: guacamole/guacamole:latest
container_name: guacamole
restart: unless-stopped
environment:
- GUACD_HOSTNAME=guacd
- MYSQL_HOSTNAME=guacamole-db
- MYSQL_DATABASE=guacamole_db
- MYSQL_USER=guacamole_user
- MYSQL_PASSWORD=guacamole_pass
ports:
- "8199:8080"
networks:
- guacamole-internal
depends_on:
guacamole-db:
condition: service_healthy
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/guacamole/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
guacd:
image: guacamole/guacd:latest
container_name: guacd
restart: unless-stopped
networks:
- guacamole-internal
mem_limit: 512M
memswap_limit: 512M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
guacamole-db:
image: mysql:8.0
container_name: guacamole-db
user: "1024:100"
restart: unless-stopped
command: --innodb-use-native-aio=0 --innodb-buffer-pool-size=128M
environment:
- MYSQL_ROOT_PASSWORD=guacamole_root
- MYSQL_DATABASE=guacamole_db
- MYSQL_USER=guacamole_user
- MYSQL_PASSWORD=guacamole_pass
volumes:
- /var/lib/guacamole-db:/var/lib/mysql
networks:
- guacamole-internal
mem_limit: 512M
memswap_limit: 512M
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
guacamole-internal:
driver: bridge
+86
View File
@@ -0,0 +1,86 @@
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse
restart: unless-stopped
environment:
- SYNAPSE_SERVER_NAME=mess.hobbs.farm
- SYNAPSE_REPORT_STATS=no
volumes:
- /mnt/docker-data/configs/matrix/data:/data
ports:
- "8008:8008"
networks:
- matrix-internal
depends_on:
synapse-db:
condition: service_healthy
mem_limit: 1G
memswap_limit: 1G
healthcheck:
test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
wellknown:
image: nginx:alpine
container_name: matrix-wellknown
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/matrix/wellknown:/usr/share/nginx/html/.well-known/matrix:ro
ports:
- "8010:80"
mem_limit: 64M
memswap_limit: 64M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
synapse-db:
image: postgres:15-alpine
container_name: synapse-db
user: "1024:100"
restart: unless-stopped
environment:
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=synapse
- POSTGRES_DB=synapse
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data
networks:
- matrix-internal
mem_limit: 256M
memswap_limit: 256M
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
matrix-internal:
driver: bridge
+151
View File
@@ -0,0 +1,151 @@
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
volumes:
- /mnt/docker-data/configs/jellyfin:/config
- /mnt/nas-media:/media
ports:
- "8096:8096"
mem_limit: 2G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8096/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
sonarr:
image: linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/sonarr:/config
- /mnt/nas-media:/media
ports:
- "8989:8989"
mem_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8989/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
radarr:
image: linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/radarr:/config
- /mnt/nas-media:/media
ports:
- "7878:7878"
mem_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:7878/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
sabnzbd:
image: linuxserver/sabnzbd:latest
container_name: sabnzbd
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/sabnzbd:/config
- /mnt/nas-media:/media
ports:
- "8082:8080"
mem_limit: 1G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api?mode=version || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
nzbhydra2:
image: linuxserver/nzbhydra2:latest
container_name: nzbhydra2
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/nzbhydra:/config
ports:
- "5076:5076"
mem_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
jellyseerr:
image: fallenbagel/jellyseerr:latest
container_name: jellyseerr
restart: unless-stopped
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/jellyseerr:/app/config
ports:
- "5055:5055"
mem_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5055/api/v1/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
+2
View File
@@ -0,0 +1,2 @@
OPENVAS_USERNAME=admin
OPENVAS_PASSWORD=change_me
+34
View File
@@ -0,0 +1,34 @@
services:
openvas:
image: immauss/openvas:latest
container_name: openvas
restart: unless-stopped
environment:
- PASSWORD=${OPENVAS_PASSWORD}
- USERNAME=${OPENVAS_USERNAME}
volumes:
- openvas-db:/data/database
- openvas-var-lib:/data/var-lib
- openvas-local-share:/data/local-share
- openvas-local-etc:/data/local-etc
- openvas-var-log:/data/var-log
ports:
- "9392:9392"
mem_limit: 6G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
volumes:
openvas-db:
driver: local
openvas-var-lib:
driver: local
openvas-local-share:
driver: local
openvas-local-etc:
driver: local
openvas-var-log:
driver: local
+65
View File
@@ -0,0 +1,65 @@
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
restart: unless-stopped
privileged: true
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/homeassistant:/config
ports:
- "8123:8123"
dns:
- 192.168.86.1
- 8.8.8.8
mem_limit: 4G
memswap_limit: 4G
depends_on:
- mosquitto
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
esphome:
image: esphome/esphome:latest
container_name: esphome
restart: unless-stopped
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/esphome/config:/config
ports:
- "6052:6052"
mem_limit: 2G
memswap_limit: 2G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:6052/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/mosquitto:/mosquitto
ports:
- "1883:1883"
- "8883:8883"
mem_limit: 128M
memswap_limit: 128M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,20 @@
services:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_MONITOR_ONLY=false
- TZ=America/Chicago
- DOCKER_API_VERSION=1.44
mem_limit: 256M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,22 @@
services:
farm-agent:
image: farm-agent:latest
container_name: farm-agent
restart: unless-stopped
ports:
- "8889:8889"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
mem_limit: 128M
memswap_limit: 128M
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8889/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -1,3 +0,0 @@
ANTHROPIC_API_KEY=<your-anthropic-api-key>
MATRIX_ACCESS_TOKEN=<matrix-bot-access-token>
MATRIX_ROOM_ID=<matrix-room-id>
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
EXPOSE 5199
CMD ["python", "-u", "main.py"]
@@ -0,0 +1,259 @@
import asyncio
import json
import logging
import os
import signal
import sys
import time
from collections import defaultdict
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread
import aiohttp
import anthropic
import paho.mqtt.client as mqtt
from vision import analyze_snapshot
from matrix_client import MatrixClient
# --- Configuration ---
MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000")
MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008")
MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"]
MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"]
HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199"))
ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"}
MIN_SCORE = 0.7
RATE_LIMIT_SECONDS = 60
DEDUP_WINDOW_SECONDS = 30
# --- Logging ---
logging.basicConfig(
level=logging.INFO,
format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}',
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
# --- State ---
recent_events: dict[str, float] = {}
last_notify_per_camera: defaultdict[str, float] = defaultdict(float)
events_processed = 0
last_event_time: str | None = None
mqtt_connected = False
# --- Health endpoint ---
class HealthHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/health":
body = json.dumps(
{
"status": "ok",
"mqtt_connected": mqtt_connected,
"events_processed": events_processed,
"last_event": last_event_time,
}
)
encoded = body.encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # suppress access logs
def start_health_server():
server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info("Health server started on port %d", HEALTH_PORT)
# --- Snapshot fetching ---
async def fetch_snapshot(event_id: str) -> bytes | None:
url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.read()
logger.info(
"Fetched snapshot for %s: %d bytes", event_id, len(data)
)
return data
logger.warning(
"Snapshot fetch failed for %s: HTTP %d", event_id, resp.status
)
except Exception:
logger.exception("Snapshot fetch error for %s", event_id)
return None
# --- Event processing ---
async def process_event(
event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient
):
global events_processed, last_event_time
event_type = event_data.get("type")
if event_type != "end":
return
after = event_data.get("after", {})
event_id = after.get("id", "")
camera = after.get("camera", "unknown")
label = after.get("label", "")
score = after.get("top_score", after.get("score", 0))
has_snapshot = after.get("has_snapshot", False)
# --- Filtering ---
if label not in ALLOWED_LABELS:
return
if score < MIN_SCORE:
return
if not has_snapshot:
return
# Dedup
now = time.time()
if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS:
return
recent_events[event_id] = now
# Rate limit per camera
if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS:
logger.info("Rate limited: %s on %s", label, camera)
return
last_notify_per_camera[camera] = now
# Cleanup old dedup entries
cutoff = now - DEDUP_WINDOW_SECONDS * 2
for eid in [k for k, v in recent_events.items() if v < cutoff]:
del recent_events[eid]
logger.info(
"Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score
)
# --- Fetch snapshot ---
snapshot = await fetch_snapshot(event_id)
# --- AI analysis ---
description = None
if snapshot:
description = analyze_snapshot(claude, snapshot)
if not description:
description = f"{label.capitalize()} detected on {camera}."
# --- Format message ---
score_pct = int(score * 100)
ts = time.strftime("%I:%M:%S %p", time.localtime())
date_str = time.strftime("%b %d, %Y", time.localtime())
frigate_link = f"{FRIGATE_URL}/review?id={event_id}"
plain = (
f"{camera} - {label.capitalize()} - {score_pct}%\n"
f"{description}\n\n"
f"View in Frigate: {frigate_link}\n"
f"{ts} - {date_str}"
)
html = (
f"<b>{camera}</b> &middot; {label.capitalize()} &middot; {score_pct}%<br/>"
f"{description}<br/><br/>"
f'<a href="{frigate_link}">View in Frigate</a><br/>'
f"<small>{ts} &middot; {date_str}</small>"
)
# --- Send to Matrix ---
if snapshot:
filename = f"{camera}_{event_id}.jpg"
mxc_uri = await matrix.upload_image(snapshot, filename)
if mxc_uri:
await matrix.send_image(mxc_uri, filename, len(snapshot))
await matrix.send_text(plain, html)
events_processed += 1
last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
logger.info("Notification sent for %s on %s", label, camera)
# --- MQTT ---
def create_mqtt_client(
loop: asyncio.AbstractEventLoop,
claude: anthropic.Anthropic,
matrix: MatrixClient,
):
global mqtt_connected
def on_connect(client, userdata, flags, reason_code, properties):
global mqtt_connected
mqtt_connected = True
client.subscribe("frigate/events")
logger.info("MQTT connected, subscribed to frigate/events")
def on_disconnect(client, userdata, flags, reason_code, properties):
global mqtt_connected
mqtt_connected = False
logger.warning("MQTT disconnected: %s", reason_code)
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload)
asyncio.run_coroutine_threadsafe(
process_event(payload, claude, matrix), loop
)
except json.JSONDecodeError:
logger.warning("Invalid JSON on %s", msg.topic)
except Exception:
logger.exception("Error processing MQTT message")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
return client
# --- Main ---
def main():
logger.info("Starting frigate-notify")
claude = anthropic.Anthropic()
matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID)
loop = asyncio.new_event_loop()
loop_thread = Thread(target=loop.run_forever, daemon=True)
loop_thread.start()
start_health_server()
mqtt_client = create_mqtt_client(loop, claude, matrix)
mqtt_client.connect(MQTT_HOST, MQTT_PORT)
def shutdown(signum, frame):
logger.info("Shutting down...")
mqtt_client.disconnect()
loop.call_soon_threadsafe(loop.stop)
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
mqtt_client.loop_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,84 @@
import asyncio
import logging
import time
import aiohttp
logger = logging.getLogger(__name__)
RETRY_DELAYS = [1, 2, 4]
class MatrixClient:
def __init__(self, homeserver: str, token: str, room_id: str):
self.homeserver = homeserver.rstrip("/")
self.token = token
self.room_id = room_id
self.headers = {"Authorization": f"Bearer {token}"}
async def _request(self, method: str, url: str, **kwargs) -> dict | None:
for attempt, delay in enumerate(RETRY_DELAYS):
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method, url, headers=self.headers, **kwargs
) as resp:
if resp.status < 300:
return await resp.json()
body = await resp.text()
logger.warning(
"Matrix API %s (attempt %d): %s",
resp.status,
attempt + 1,
body,
)
except Exception:
logger.exception("Matrix request failed (attempt %d)", attempt + 1)
if attempt < len(RETRY_DELAYS) - 1:
await asyncio.sleep(delay)
logger.error("All Matrix retries exhausted for %s", url)
return None
async def upload_image(self, image_bytes: bytes, filename: str) -> str | None:
url = f"{self.homeserver}/_matrix/media/v3/upload?filename={filename}"
headers = {**self.headers, "Content-Type": "image/jpeg"}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=image_bytes) as resp:
if resp.status < 300:
data = await resp.json()
mxc_uri = data.get("content_uri")
logger.info("Uploaded image: %s", mxc_uri)
return mxc_uri
body = await resp.text()
logger.error("Image upload failed %s: %s", resp.status, body)
except Exception:
logger.exception("Image upload failed")
return None
async def send_image(self, mxc_uri: str, filename: str, size: int) -> dict | None:
txn_id = str(int(time.time() * 1000))
url = (
f"{self.homeserver}/_matrix/client/r0/rooms/"
f"{self.room_id}/send/m.room.message/{txn_id}"
)
body = {
"msgtype": "m.image",
"body": filename,
"url": mxc_uri,
"info": {"mimetype": "image/jpeg", "size": size},
}
return await self._request("PUT", url, json=body)
async def send_text(self, plain: str, html: str) -> dict | None:
txn_id = str(int(time.time() * 1000) + 1)
url = (
f"{self.homeserver}/_matrix/client/r0/rooms/"
f"{self.room_id}/send/m.room.message/{txn_id}"
)
body = {
"msgtype": "m.text",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
}
return await self._request("PUT", url, json=body)
@@ -0,0 +1,3 @@
paho-mqtt==2.1.0
anthropic>=0.40.0
aiohttp>=3.9.0
@@ -0,0 +1,42 @@
import base64
import logging
import anthropic
logger = logging.getLogger(__name__)
PROMPT = (
"Briefly describe what's happening in this security camera image in 1-2 sentences. "
"Focus on the people, vehicles, or animals and their activity. Be concise."
)
def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | None:
"""Send a snapshot to Claude Haiku for description. Returns None on failure."""
try:
b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=150,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": b64,
},
},
{"type": "text", "text": PROMPT},
],
}
],
)
description = response.content[0].text.strip()
logger.info("Vision analysis complete: %d chars", len(description))
return description
except Exception:
logger.exception("Claude Vision API failed")
return None
+488
View File
@@ -0,0 +1,488 @@
# =============================================================================
# Frigate NVR Configuration - Optimized (v4)
# =============================================================================
#
# 17 cameras total:
#
# Reolink (h264Preview paths, H.264):
# back_porch 192.168.44.63 kerberos:kerberos
# house_south_corner 192.168.44.61 admin:(blank)
# house_south_yard 192.168.44.62 admin:(blank)
# gate_camera 192.168.86.175 admin:(blank)
#
# Profile cameras (/profile1 + /profile2, H.264):
# house_north 192.168.44.51 admin:123456
# house_south 192.168.44.53 admin:123456
# house_west 192.168.44.52 admin:123456
# shop_west 192.168.44.56 admin:123456
# shop_driveway 192.168.44.57 admin:123456
# shop_east 192.168.44.55 admin:123456
#
# Stream camera (/stream1 + /stream2, HEVC):
# house_east 192.168.44.58 admin:admin
#
# Dahua-style (/cam/realmonitor, codec TBD):
# garage_2 192.168.44.64 admin:7rip8a115
#
# Amcrest w/ authbasic=64 (/cam/realmonitor):
# driveway_1 192.168.86.150 admin:7rip8a115
# driveway_2 192.168.44.68 admin:7rip8a115
# front_door_view 192.168.44.59 admin:7rip8a115
#
# Amcrest ASH43-W (HEVC, /cam/realmonitor + authbasic=64):
# pool_room 192.168.86.142 admin:bigdog01
# front_house 192.168.44.41 admin:bigdog01
#
# =============================================================================
version: 0.17-0
auth:
enabled: true
mqtt:
enabled: true
host: 192.168.86.192
port: 1883
ui:
timezone: America/Chicago
database:
path: /config/frigate.db
# =============================================================================
# go2rtc - Single connection per camera, restreamed locally
# =============================================================================
go2rtc:
webrtc:
candidates:
- 192.168.86.192:8555
- stun:8555
streams:
# --- Reolink cameras (h264Preview paths) ---------------------------------
back_porch:
- rtsp://kerberos:kerberos@192.168.44.63:554/h264Preview_01_main
back_porch_sub:
- rtsp://kerberos:kerberos@192.168.44.63:554/h264Preview_01_sub
house_south_corner:
- rtsp://admin:@192.168.44.61:554/h264Preview_01_main
house_south_corner_sub:
- rtsp://admin:@192.168.44.61:554/h264Preview_01_sub
house_south_yard:
- rtsp://admin:@192.168.44.62:554/h264Preview_01_main
house_south_yard_sub:
- rtsp://admin:@192.168.44.62:554/h264Preview_01_sub
gate_camera:
- rtsp://admin:@192.168.86.175:554/h264Preview_01_main
gate_camera_sub:
- rtsp://admin:@192.168.86.175:554/h264Preview_01_sub
# --- Profile cameras (/profile1 + /profile2) ----------------------------
house_north:
- rtsp://admin:123456@192.168.44.51:554/profile1
house_north_sub:
- rtsp://admin:123456@192.168.44.51:554/profile2
house_south:
- rtsp://admin:123456@192.168.44.53:554/profile1
house_south_sub:
- rtsp://admin:123456@192.168.44.53:554/profile2
house_west:
- rtsp://admin:123456@192.168.44.52:554/
house_west_sub:
- rtsp://admin:123456@192.168.44.52:554/profile2
shop_west:
- rtsp://admin:123456@192.168.44.56:554/profile1
shop_west_sub:
- rtsp://admin:123456@192.168.44.56:554/profile2
shop_driveway:
- rtsp://admin:123456@192.168.44.57:554/profile1
shop_driveway_sub:
- rtsp://admin:123456@192.168.44.57:554/profile2
shop_east:
- rtsp://admin:123456@192.168.44.55:554/profile1
shop_east_sub:
- rtsp://admin:123456@192.168.44.55:554/profile2
# --- Stream camera (HEVC) ------------------------------------------------
house_east:
- rtsp://admin:admin@192.168.44.58:554/stream1#video=h264
house_east_sub:
- rtsp://admin:admin@192.168.44.58:554/stream2
# --- garage_2 - Dahua-style, NOT Amcrest ---------------------------------
garage_2:
- rtsp://admin:7rip8a115@192.168.44.64:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
garage_2_sub:
- rtsp://admin:7rip8a115@192.168.44.64:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif
# --- Amcrest cameras w/ authbasic=64 -------------------------------------
driveway_1:
- rtsp://admin:7rip8a115@192.168.86.150:554/cam/realmonitor?channel=1&subtype=0&authbasic=64
driveway_1_sub:
- rtsp://admin:7rip8a115@192.168.86.150:554/cam/realmonitor?channel=1&subtype=1&authbasic=64
driveway_2:
- rtsp://admin:7rip8a115@192.168.44.68:554/cam/realmonitor?channel=1&subtype=0&authbasic=64
driveway_2_sub:
- rtsp://admin:7rip8a115@192.168.44.68:554/cam/realmonitor?channel=1&subtype=1&authbasic=64
front_door_view:
- rtsp://admin:7rip8a115@192.168.44.59:554/cam/realmonitor?channel=1&subtype=0&authbasic=64
front_door_view_sub:
- rtsp://admin:7rip8a115@192.168.44.59:554/cam/realmonitor?channel=1&subtype=1&authbasic=64
# --- ASH43-W cameras (HEVC, authbasic=64) --------------------------------
pool_room:
- rtsp://admin:bigdog01@192.168.86.142:554/cam/realmonitor?channel=1&subtype=0&authbasic=64#video=h264
pool_room_sub:
- rtsp://admin:bigdog01@192.168.86.142:554/cam/realmonitor?channel=1&subtype=1&authbasic=64
front_house:
- rtsp://admin:bigdog01@192.168.44.41:554/cam/realmonitor?channel=1&subtype=0&authbasic=64#video=h264
front_house_sub:
- rtsp://admin:bigdog01@192.168.44.41:554/cam/realmonitor?channel=1&subtype=1&authbasic=64
# =============================================================================
# Global ffmpeg - VAAPI hardware acceleration
# =============================================================================
ffmpeg:
hwaccel_args: preset-vaapi
# =============================================================================
model:
path: /openvino-model/ssdlite_mobilenet_v2.xml
width: 300
height: 300
input_tensor: nhwc
input_pixel_format: bgr
labelmap_path: /openvino-model/coco_91cl_bkgr.txt
detectors:
ov:
type: openvino
device: GPU
detect:
enabled: true
fps: 3
width: 640
height: 480
# =============================================================================
# Recording
# =============================================================================
record:
enabled: true
continuous:
days: 2
alerts:
retain:
days: 7
detections:
retain:
days: 7
# =============================================================================
# Snapshots
# =============================================================================
snapshots:
enabled: true
clean_copy: true
retain:
default: 7
mode: motion
# =============================================================================
# Object tracking
# =============================================================================
objects:
track:
- person
- car
filters:
person:
min_area: 5000
threshold: 0.7
car:
min_area: 10000
threshold: 0.7
# =============================================================================
# Birdseye - overview of all cameras
# =============================================================================
birdseye:
enabled: true
mode: objects
# =============================================================================
# Camera definitions
# =============================================================================
cameras:
# ---------------------------------------------------------------------------
# Reolink cameras (H.264, VAAPI OK)
# ---------------------------------------------------------------------------
back_porch:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/back_porch_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/back_porch
input_args: preset-rtsp-restream
roles:
- record
house_south_corner:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/house_south_corner_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_south_corner
input_args: preset-rtsp-restream
roles:
- record
house_south_yard:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/house_south_yard_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_south_yard
input_args: preset-rtsp-restream
roles:
- record
gate_camera:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/gate_camera_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/gate_camera
input_args: preset-rtsp-restream
roles:
- record
# ---------------------------------------------------------------------------
# Profile cameras (H.264, VAAPI OK)
# ---------------------------------------------------------------------------
house_north:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/house_north_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_north
input_args: preset-rtsp-restream
roles:
- record
house_south:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/house_south_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_south
input_args: preset-rtsp-restream
roles:
- record
house_west:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/house_west_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_west
input_args: preset-rtsp-restream
roles:
- record
shop_west:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/shop_west_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/shop_west
input_args: preset-rtsp-restream
roles:
- record
shop_driveway:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/shop_driveway_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/shop_driveway
input_args: preset-rtsp-restream
roles:
- record
shop_east:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/shop_east_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/shop_east
input_args: preset-rtsp-restream
roles:
- record
# ---------------------------------------------------------------------------
# house_east - HEVC, VAAPI disabled (pw: admin)
# ---------------------------------------------------------------------------
house_east:
enabled: true
ffmpeg:
hwaccel_args: []
inputs:
- path: rtsp://127.0.0.1:8554/house_east_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/house_east
input_args: preset-rtsp-restream
roles:
- record
# ---------------------------------------------------------------------------
# garage_2 - Dahua-style, codec TBD, VAAPI disabled as precaution
# If H.264 confirmed, remove hwaccel_args override
# ---------------------------------------------------------------------------
garage_2:
enabled: true
ffmpeg:
hwaccel_args: []
inputs:
- path: rtsp://127.0.0.1:8554/garage_2_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/garage_2
input_args: preset-rtsp-restream
roles:
- record
# ---------------------------------------------------------------------------
# Amcrest cameras w/ authbasic=64
# Codec unknown - VAAPI disabled as precaution until confirmed H.264
# ---------------------------------------------------------------------------
driveway_1:
enabled: false
ffmpeg:
hwaccel_args: []
inputs:
- path: rtsp://127.0.0.1:8554/driveway_1_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/driveway_1
input_args: preset-rtsp-restream
roles:
- record
driveway_2:
enabled: false
ffmpeg:
hwaccel_args: []
inputs:
- path: rtsp://127.0.0.1:8554/driveway_2_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/driveway_2
input_args: preset-rtsp-restream
roles:
- record
front_door_view:
enabled: false
ffmpeg:
hwaccel_args: []
inputs:
- path: rtsp://127.0.0.1:8554/front_door_view_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/front_door_view
input_args: preset-rtsp-restream
roles:
- record
# ---------------------------------------------------------------------------
# ASH43-W cameras (HEVC main, H.264 sub - go2rtc transcodes main to H.264)
# ---------------------------------------------------------------------------
pool_room:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/pool_room_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/pool_room
input_args: preset-rtsp-restream
roles:
- record
front_house:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/front_house_sub
input_args: preset-rtsp-restream
roles:
- detect
- path: rtsp://127.0.0.1:8554/front_house
input_args: preset-rtsp-restream
roles:
- record
@@ -0,0 +1,30 @@
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
container_name: frigate
restart: unless-stopped
privileged: true
shm_size: "512mb"
network_mode: host
environment:
- TZ=America/Chicago
- FRIGATE_RTSP_PASSWORD=password
volumes:
- /mnt/docker-data/configs/frigate/config:/config
- /mnt/frigate-recordings:/media/frigate
- /etc/localtime:/etc/localtime:ro
devices:
- /dev/dri:/dev/dri
mem_limit: 12G
memswap_limit: 12G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000/api/version || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,22 @@
services:
actual-budget:
image: actualbudget/actual-server:latest
container_name: actual-budget
restart: unless-stopped
ports:
- "5006:5006"
volumes:
- /mnt/docker-data/configs/actual-budget:/data
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:5006/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,26 @@
services:
adguardhome:
image: adguard/adguardhome:latest
container_name: adguardhome
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/adguardhome/work:/opt/adguardhome/work
- /mnt/docker-data/configs/adguardhome/conf:/opt/adguardhome/conf
ports:
- "53:53/tcp"
- "53:53/udp"
- "3000:3000/tcp"
- "853:853/tcp"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,20 @@
services:
caddy-vw:
image: caddy:alpine
container_name: caddy-vw
restart: unless-stopped
network_mode: host
volumes:
- /mnt/docker-data/configs/caddy-vw/Caddyfile:/etc/caddy/Caddyfile:ro
- /mnt/docker-data/configs/caddy-vw/data:/data
- /mnt/docker-data/configs/caddy-vw/config:/config
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
mem_limit: 256M
memswap_limit: 256M
labels:
- "com.centurylinklabs.watchtower.enable=false"
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
+1
View File
@@ -0,0 +1 @@
GITEA_DB_PASSWORD=change_me
@@ -0,0 +1,72 @@
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "3003:3000"
- "2222:22"
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${GITEA_DB_PASSWORD}
- GITEA__server__SSH_PORT=2222
volumes:
- /mnt/docker-data/configs/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
gitea-db:
condition: service_healthy
networks:
- gitea-internal
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
gitea-db:
image: postgres:16-alpine
container_name: gitea-db
restart: unless-stopped
user: "1024:100"
environment:
- POSTGRES_DB=gitea
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
volumes:
- /mnt/docker-data/databases/gitea-db:/var/lib/postgresql/data
networks:
- gitea-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
gitea-internal:
driver: bridge
@@ -0,0 +1,23 @@
services:
mcpo:
image: ghcr.io/open-webui/mcpo:main
container_name: mcpo
restart: unless-stopped
ports:
- "8001:8000"
volumes:
- /mnt/docker-data/configs/mcpo:/app/config
command: ["--config", "/app/config/config.json"]
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/openapi.json || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,158 @@
networks:
monitoring-net:
name: monitoring-net
services:
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
user: "104"
networks:
- monitoring-net
ports:
- "3001:3000"
volumes:
- /mnt/docker-data/configs/grafana:/var/lib/grafana
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
dns_search: ""
extra_hosts:
- "blackbox-exporter:172.24.0.4"
- "alertmanager:172.24.0.5"
- "influxdb:172.24.0.7"
networks:
- monitoring-net
ports:
- "9095:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
volumes:
- /mnt/docker-data/configs/prometheus:/etc/prometheus
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
alertmanager:
image: prom/alertmanager:latest
container_name: alertmanager
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9093:9093"
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
- /mnt/docker-data/configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
influxdb:
image: influxdb:1.8-alpine
container_name: influxdb
restart: unless-stopped
networks:
- monitoring-net
ports:
- "8086:8086"
volumes:
- /mnt/docker-data/configs/influxdb:/var/lib/influxdb
mem_limit: 1G
memswap_limit: 1G
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
matrix-alertmanager-receiver:
image: metio/matrix-alertmanager-receiver:2026.2.25
container_name: matrix-alertmanager-receiver
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9094:9094"
volumes:
- /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro
command: ["--config-path", "/config/config.yml"]
mem_limit: 128M
memswap_limit: 128M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
blackbox-exporter:
image: prom/blackbox-exporter:latest
container_name: blackbox-exporter
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9115:9115"
command:
- '--config.file=/config/blackbox.yml'
volumes:
- /mnt/docker-data/configs/blackbox:/config:ro
mem_limit: 128M
memswap_limit: 128M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
volumes:
alertmanager-data:
+2
View File
@@ -0,0 +1,2 @@
N8N_DB_PASSWORD=change_me
N8N_ENCRYPTION_KEY=change_me_32_char_random_key_here
@@ -0,0 +1,70 @@
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
ports:
- "5678:5678"
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=n8n-db
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
- N8N_PORT=5678
- GENERIC_TIMEZONE=America/Chicago
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
volumes:
- /mnt/docker-data/configs/n8n:/home/node/.n8n
depends_on:
n8n-db:
condition: service_healthy
networks:
- n8n-internal
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
n8n-db:
image: postgres:15-alpine
container_name: n8n-db
restart: unless-stopped
user: "1024:100"
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
volumes:
- /mnt/docker-data/databases/n8n-db:/var/lib/postgresql/data
networks:
- n8n-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
n8n-internal:
driver: bridge
@@ -0,0 +1,2 @@
ANTHROPIC_API_KEY=sk-ant-...
MATRIX_ACCESS_TOKEN=syt_...
+38
View File
@@ -0,0 +1,38 @@
FROM node:20-bookworm
# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 python3-pip python3-venv python3-full \
git curl jq openssh-client iputils-ping net-tools \
&& rm -rf /var/lib/apt/lists/*
# Install uv (Python package manager used by some MCP servers)
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Install OpenClaw globally
RUN npm install -g openclaw@latest
# Clone NetClaw
WORKDIR /opt/netclaw
RUN git clone https://github.com/automateyournetwork/netclaw.git .
# Create openclaw home directory
RUN mkdir -p /root/.openclaw/workspace
# Copy NetClaw workspace config into openclaw home
RUN cp -r workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
RUN cp config/openclaw.json /root/.openclaw/openclaw.json 2>/dev/null || true
# Install Python dependencies for core MCP servers
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir pyats genie netmiko paramiko requests pyyaml jinja2 2>/dev/null || true
# Create entrypoint script
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh
EXPOSE 1179
ENTRYPOINT ["/opt/entrypoint.sh"]
@@ -0,0 +1,20 @@
services:
netclaw:
build:
context: .
dockerfile: Dockerfile
container_name: netclaw-aaron
restart: unless-stopped
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN}
- MATRIX_HOMESERVER=http://192.168.86.192:8008
volumes:
- /mnt/docker-data/configs/netclaw:/root/.openclaw/data
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,72 @@
#!/bin/bash
set -e
# Configure OpenClaw environment
mkdir -p /root/.openclaw
# Write .env with API key
cat > /root/.openclaw/.env << EOF
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
EOF
# Write openclaw config for gateway mode
cat > /root/.openclaw/openclaw.json << 'OCEOF'
{
"mode": "gateway",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"fallbackModel": "claude-haiku-4-5-20251001",
"name": "Aaron",
"identity": "Aaron - NetClaw AI Network Engineering Agent"
}
OCEOF
# Copy workspace skills if they exist
if [ -d /opt/netclaw/workspace/skills ]; then
cp -r /opt/netclaw/workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
fi
# Copy personality/identity docs
for doc in SOUL.md AGENTS.md IDENTITY.md USER.md TOOLS.md; do
if [ -f /opt/netclaw/$doc ]; then
cp /opt/netclaw/$doc /root/.openclaw/ 2>/dev/null || true
fi
done
# Send Matrix DM to Jeremy to announce availability
if [ -n "$MATRIX_ACCESS_TOKEN" ] && [ -n "$MATRIX_HOMESERVER" ]; then
echo "Sending Matrix DM to Jeremy..."
# Create a direct message room with Jeremy
ROOM_RESPONSE=$(curl -sf -X POST "${MATRIX_HOMESERVER}/_matrix/client/r0/createRoom" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
-d "{
\"is_direct\": true,
\"invite\": [\"@jeremy:hobbs.farm\"],
\"preset\": \"trusted_private_chat\"
}" 2>/dev/null || echo "")
ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || echo "")
if [ -n "$ROOM_ID" ]; then
# Send the message
curl -sf -X PUT "${MATRIX_HOMESERVER}/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/$(date +%s%N)" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
-d "{
\"msgtype\": \"m.text\",
\"body\": \"Hey Jeremy - Aaron here. I'm online and ready to help with network engineering tasks. I'm running NetClaw with CCIE-level networking skills. Ask me about device health checks, routing analysis, security audits, or anything network-related.\"
}" 2>/dev/null || echo "Failed to send DM"
echo "DM sent to @jeremy:hobbs.farm in room $ROOM_ID"
else
echo "Warning: Could not create DM room with Jeremy. Response: $ROOM_RESPONSE"
fi
fi
# Start OpenClaw gateway
echo "Starting NetClaw (Aaron) gateway..."
exec openclaw gateway 2>&1 || {
echo "OpenClaw gateway failed to start, keeping container alive for debugging..."
tail -f /dev/null
}
@@ -0,0 +1,4 @@
SEMAPHORE_DB_PASSWORD=change_me
SEMAPHORE_ADMIN_PASSWORD=change_me
SEMAPHORE_ENCRYPTION_KEY=change_me_base64_encoded_32_byte_key
SEMAPHORE_MYSQL_ROOT_PASSWORD=change_me
@@ -0,0 +1,78 @@
services:
semaphore:
image: semaphoreui/semaphore:latest
container_name: semaphore
restart: unless-stopped
ports:
- "3004:3000"
environment:
- SEMAPHORE_DB_DIALECT=mysql
- SEMAPHORE_DB_HOST=semaphore-db
- SEMAPHORE_DB_PORT=3306
- SEMAPHORE_DB_USER=semaphore
- SEMAPHORE_DB_PASS=${SEMAPHORE_DB_PASSWORD}
- SEMAPHORE_DB_NAME=semaphore
- SEMAPHORE_DB=semaphore
- SEMAPHORE_PLAYBOOK_PATH=/tmp/semaphore
- SEMAPHORE_ADMIN_PASSWORD=${SEMAPHORE_ADMIN_PASSWORD}
- SEMAPHORE_ADMIN_NAME=admin
- SEMAPHORE_ADMIN_EMAIL=admin@hobbs.farm
- SEMAPHORE_ADMIN=admin
- SEMAPHORE_ACCESS_KEY_ENCRYPTION=${SEMAPHORE_ENCRYPTION_KEY}
- SEMAPHORE_LDAP_ACTIVATED=no
volumes:
- /mnt/docker-data/configs/semaphore:/etc/semaphore
- /mnt/docker-data/configs/semaphore/playbooks:/tmp/semaphore
depends_on:
semaphore-db:
condition: service_healthy
networks:
- semaphore-internal
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
semaphore-db:
image: mysql:8.0
container_name: semaphore-db
restart: unless-stopped
user: "1024:100"
environment:
- MYSQL_ROOT_PASSWORD=${SEMAPHORE_MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=semaphore
- MYSQL_USER=semaphore
- MYSQL_PASSWORD=${SEMAPHORE_DB_PASSWORD}
volumes:
- /mnt/docker-data/databases/semaphore-db:/var/lib/mysql
networks:
- semaphore-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
command: --innodb-buffer-pool-size=128M --innodb-log-file-size=32M
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
semaphore-internal:
driver: bridge
@@ -0,0 +1 @@
CF_DNS_API_TOKEN=change_me_cloudflare_api_token
@@ -0,0 +1,39 @@
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
command:
- "--api.insecure=true"
- "--providers.file.directory=/config/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.letsencrypt.acme.email=jeremydanielhobbs@gmail.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
ports:
- "80:80"
- "443:443"
- "8090:8080"
volumes:
- /mnt/docker-data/traefik/letsencrypt:/letsencrypt
- /mnt/docker-data/traefik/dynamic:/config/dynamic:ro
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/api/overview || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,88 @@
http:
routers:
yoda:
rule: "Host(`yoda.hobbs.farm`)"
service: yoda
entryPoints:
- websecure
tls:
certResolver: letsencrypt
mess:
rule: "Host(`mess.hobbs.farm`)"
service: mess
entryPoints:
- websecure
tls:
certResolver: letsencrypt
mess-federation:
rule: "Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)"
service: mess
entryPoints:
- websecure
tls:
certResolver: letsencrypt
wellknown:
rule: "Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)"
service: wellknown
entryPoints:
- websecure
tls:
certResolver: letsencrypt
solo:
rule: "Host(`solo.hobbs.farm`)"
service: solo
entryPoints:
- websecure
tls:
certResolver: letsencrypt
vault:
rule: "Host(`vault.hobbs.farm`)"
service: vault
entryPoints:
- websecure
tls:
certResolver: letsencrypt
git:
rule: "Host(`git.hobbs.farm`)"
service: git
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
yoda:
loadBalancer:
servers:
- url: "http://192.168.86.167:8123"
mess:
loadBalancer:
servers:
- url: "http://192.168.86.167:8008"
wellknown:
loadBalancer:
servers:
- url: "http://192.168.86.167:8010"
solo:
loadBalancer:
servers:
- url: "http://192.168.86.167:8199"
vault:
loadBalancer:
servers:
- url: "http://192.168.86.100:26226"
git:
loadBalancer:
servers:
- url: "http://192.168.86.100:3003"
@@ -0,0 +1,21 @@
services:
trivy:
image: aquasec/trivy:latest
container_name: trivy
restart: unless-stopped
command: server --listen 0.0.0.0:4954
ports:
- "4954:4954"
volumes:
- trivy-cache:/root/.cache/
- /var/run/docker.sock:/var/run/docker.sock:ro
mem_limit: 512M
memswap_limit: 512M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
volumes:
trivy-cache:
+2
View File
@@ -0,0 +1,2 @@
UNIFI_MONGO_USER=unifi
UNIFI_MONGO_PASS=change_me
@@ -0,0 +1,67 @@
services:
unifi:
image: lscr.io/linuxserver/unifi-network-application:latest
container_name: unifi
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
- MONGO_USER=${UNIFI_MONGO_USER}
- MONGO_PASS=${UNIFI_MONGO_PASS}
- MONGO_HOST=unifi-db
- MONGO_PORT=27017
- MONGO_DBNAME=unifi
volumes:
- /mnt/docker-data/configs/unifi/data:/config
ports:
- "8443:8443"
- "3478:3478/udp"
- "10001:10001/udp"
- "8080:8080"
- "1900:1900/udp"
- "8843:8843"
- "8880:8880"
- "6789:6789"
- "5514:5514/udp"
depends_on:
unifi-db:
condition: service_healthy
networks:
- unifi-internal
mem_limit: 1G
memswap_limit: 1G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
unifi-db:
image: mongo:7.0
container_name: unifi-db
restart: unless-stopped
user: "1024:100"
volumes:
- /mnt/docker-data/databases/unifi-db:/data/db
networks:
- unifi-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
mem_limit: 1G
memswap_limit: 1G
healthcheck:
test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' --quiet"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
unifi-internal:
driver: bridge
@@ -0,0 +1,25 @@
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
- DOMAIN=https://vault.hobbs.farm
- SIGNUPS_ALLOWED=false
volumes:
- /mnt/docker-data/configs/vaultwarden:/data
ports:
- "26226:80"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/alive || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,21 @@
services:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_MONITOR_ONLY=false
- TZ=America/Chicago
- DOCKER_API_VERSION=1.44
mem_limit: 256M
memswap_limit: 256M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1 @@
WIKIJS_DB_PASSWORD=change_me
@@ -0,0 +1,65 @@
services:
wikijs:
image: ghcr.io/requarks/wiki:2
container_name: wikijs
restart: unless-stopped
ports:
- "3002:3000"
environment:
- DB_TYPE=postgres
- DB_HOST=wikijs-db
- DB_PORT=5432
- DB_USER=wikijs
- DB_PASS=${WIKIJS_DB_PASSWORD}
- DB_NAME=wiki
depends_on:
wikijs-db:
condition: service_healthy
networks:
- wikijs-internal
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
wikijs-db:
image: postgres:15-alpine
container_name: wikijs-db
restart: unless-stopped
user: "1024:100"
environment:
- POSTGRES_DB=wiki
- POSTGRES_USER=wikijs
- POSTGRES_PASSWORD=${WIKIJS_DB_PASSWORD}
volumes:
- /mnt/docker-data/databases/wikijs-db:/var/lib/postgresql/data
networks:
- wikijs-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wikijs -d wiki"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
wikijs-internal:
driver: bridge
@@ -0,0 +1,112 @@
# Frigate Video Alerts via Matrix
## Summary
A lightweight Python container that listens for Frigate object detection events via MQTT, analyzes snapshots with Claude Vision (Haiku), and posts rich notifications to a dedicated Matrix room with inline images and AI-generated descriptions.
## Architecture
```
Frigate NVR (hf-pdocker-01)
| MQTT: frigate/events
v
Mosquitto Broker (:1883)
|
v
frigate-notify (Python container, hf-pdocker-01, host network)
|-- Frigate API: GET /api/events/{id}/snapshot.jpg
|-- Claude Vision API: analyze snapshot (Haiku)
|-- Matrix API: upload image + send message
v
#video-alerts:hobbs.farm (@frigate:hobbs.farm bot)
```
## Event Flow & Filtering
**MQTT topic:** `frigate/events`
**Filtering:**
1. Only `type == "end"` events (complete detection, best snapshot)
2. Only `label` in `["person", "car", "dog", "cat", "bird"]`
3. Only `score >= 0.7`
4. Only if `has_snapshot == true`
5. Deduplication: skip duplicate event IDs within 30-second window per camera
6. Rate limit: max 1 notification per camera per 60 seconds
## AI Vision Analysis
- Model: `claude-haiku-4-5-20251001` (~$0.001/event, ~$1.50/month at 50 events/day)
- Prompt: "Briefly describe what's happening in this security camera image in 1-2 sentences. Focus on the people, vehicles, or animals and their activity. Be concise."
- Max tokens: 150
- Fallback: if Claude API fails, use structured description ("Person detected on {camera}")
## Matrix Message Format
Two messages per event:
1. **Image message** (`m.image`): snapshot uploaded to Matrix content repository
2. **Text message** (`m.text` with HTML):
```
[camera_emoji] camera_name - Object - score%
AI-generated description of the scene.
Link: http://192.168.86.192:5000/review?id={event_id}
Timestamp
```
**Bot:** `@frigate:hobbs.farm` (new dedicated account)
**Room:** `#video-alerts:hobbs.farm` (new room, @jeremy:hobbs.farm invited)
## Container & Deployment
**Location:** `compose/hf-pdocker-01/frigate-notify/`
```
compose/hf-pdocker-01/frigate-notify/
docker-compose.yml
.env
Dockerfile
app/
main.py (~200 lines - MQTT listener, event processor)
vision.py (~40 lines - Claude Vision API wrapper)
matrix_client.py (~60 lines - image upload + message send)
requirements.txt
```
**Container config:**
- Image: custom, from `python:3.12-slim`
- Network: host
- Memory: 256M (memswap_limit: 256M)
- Stateless (no persistent storage)
- Health endpoint: port 5199, returns JSON status
**Environment variables (.env):**
- `ANTHROPIC_API_KEY`
- `MATRIX_ACCESS_TOKEN`
- `MATRIX_ROOM_ID`
- `FRIGATE_URL=http://192.168.86.192:5000`
- `MQTT_HOST=192.168.86.192`
- `MQTT_PORT=1883`
## Error Handling
- **Claude API failure:** fall back to structured message, never drop alert
- **Matrix API failure:** retry 3x with exponential backoff (1s, 2s, 4s)
- **Snapshot fetch failure:** send text-only notification
- **MQTT disconnect:** paho-mqtt auto-reconnects
- **Invalid payload:** log and skip
## Observability
- Structured JSON logging to stdout
- Health endpoint at `:5199/health` with MQTT status, event count, last event time
- Added to Prometheus blackbox probes (http-pdocker01 job)
- ContainerDown alert covers it automatically
## Decisions
- **Haiku over Sonnet:** 10x cheaper, fast enough for security descriptions
- **host network:** Frigate, Mosquitto, Synapse all on LAN IPs, simplest routing
- **aiohttp over matrix-nio:** lighter, no crypto dependencies needed for bot usage
- **Two messages (image + text):** image renders natively across all Matrix clients
- **End events only:** best snapshot quality, avoids duplicate notifications
@@ -0,0 +1,713 @@
# Frigate Video Alerts Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Python container that listens for Frigate detection events via MQTT, analyzes snapshots with Claude Vision, and posts rich notifications to a Matrix room.
**Architecture:** A single Python container (`frigate-notify`) on hf-pdocker-01 subscribes to Frigate's MQTT events, filters for person/car/animal detections, fetches snapshots from Frigate's API, sends them to Claude Haiku for natural language description, then posts the snapshot image and description to a dedicated `#video-alerts` Matrix room via a new `@frigate:hobbs.farm` bot.
**Tech Stack:** Python 3.12, paho-mqtt, anthropic SDK, aiohttp, Docker Compose
**Design doc:** `docs/plans/2026-03-04-frigate-video-alerts-design.md`
---
### Task 1: Create Matrix bot account and room
This task runs on hf-pdocker-01 via SSH. No code files involved.
**Step 1: Register @frigate:hobbs.farm bot account**
Use the docker-swarm-ops agent to SSH to hf-pdocker-01 (192.168.86.192) and run:
```bash
# Register the bot via Synapse admin API
# First get an admin token (use @alertbot or register directly)
docker exec $(docker ps -q -f name=matrix_synapse) register_new_matrix_user \
-u frigate \
-p "FrigateBot2026!" \
-a \
--no-admin \
-c /data/homeserver.yaml \
http://localhost:8008
```
**Step 2: Login to get access token**
```bash
curl -s -X POST http://192.168.86.192:8008/_matrix/client/r0/login \
-H "Content-Type: application/json" \
-d '{"type":"m.login.password","user":"frigate","password":"FrigateBot2026!"}'
```
Save the `access_token` from the response.
**Step 3: Create #video-alerts room**
```bash
curl -s -X POST "http://192.168.86.192:8008/_matrix/client/r0/createRoom" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{
"room_alias_name": "video-alerts",
"name": "Video Alerts",
"topic": "Frigate NVR detection alerts with AI descriptions",
"visibility": "private",
"invite": ["@jeremy:hobbs.farm"]
}'
```
Save the `room_id` from the response.
**Step 4: Send a test message to verify**
```bash
curl -s -X PUT "http://192.168.86.192:8008/_matrix/client/r0/rooms/<ROOM_ID>/send/m.room.message/$(date +%s)" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"msgtype":"m.text","body":"Frigate video alerts bot is online."}'
```
**Step 5: Store credentials in vault**
Use the vault-credential-manager agent to store:
- Name: "Frigate Matrix Bot"
- Username: @frigate:hobbs.farm
- Password: FrigateBot2026!
- Notes: access_token=<TOKEN>, room_id=<ROOM_ID>
**Step 6: Commit** (nothing to commit yet - this is infrastructure setup)
---
### Task 2: Create the Python application files
**Files:**
- Create: `compose/hf-pdocker-01/frigate-notify/app/requirements.txt`
- Create: `compose/hf-pdocker-01/frigate-notify/app/vision.py`
- Create: `compose/hf-pdocker-01/frigate-notify/app/matrix_client.py`
- Create: `compose/hf-pdocker-01/frigate-notify/app/main.py`
**Step 1: Create requirements.txt**
```
compose/hf-pdocker-01/frigate-notify/app/requirements.txt
```
```text
paho-mqtt==2.1.0
anthropic>=0.40.0
aiohttp>=3.9.0
```
**Step 2: Create vision.py - Claude Vision wrapper**
```
compose/hf-pdocker-01/frigate-notify/app/vision.py
```
```python
import base64
import logging
import anthropic
logger = logging.getLogger(__name__)
PROMPT = (
"Briefly describe what's happening in this security camera image in 1-2 sentences. "
"Focus on the people, vehicles, or animals and their activity. Be concise."
)
def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | None:
"""Send a snapshot to Claude Haiku for description. Returns None on failure."""
try:
b64 = base64.standard_b64encode(image_bytes).decode("utf-8")
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=150,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": b64,
},
},
{"type": "text", "text": PROMPT},
],
}
],
)
description = response.content[0].text.strip()
logger.info("Vision analysis complete: %d chars", len(description))
return description
except Exception:
logger.exception("Claude Vision API failed")
return None
```
**Step 3: Create matrix_client.py - Matrix image upload and messaging**
```
compose/hf-pdocker-01/frigate-notify/app/matrix_client.py
```
```python
import logging
import time
import aiohttp
logger = logging.getLogger(__name__)
RETRY_DELAYS = [1, 2, 4]
class MatrixClient:
def __init__(self, homeserver: str, token: str, room_id: str):
self.homeserver = homeserver.rstrip("/")
self.token = token
self.room_id = room_id
self.headers = {"Authorization": f"Bearer {token}"}
async def _request(self, method: str, url: str, **kwargs) -> dict | None:
for attempt, delay in enumerate(RETRY_DELAYS):
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method, url, headers=self.headers, **kwargs
) as resp:
if resp.status < 300:
return await resp.json()
body = await resp.text()
logger.warning(
"Matrix API %s (attempt %d): %s", resp.status, attempt + 1, body
)
except Exception:
logger.exception("Matrix request failed (attempt %d)", attempt + 1)
if attempt < len(RETRY_DELAYS) - 1:
await _sleep(delay)
logger.error("All Matrix retries exhausted for %s", url)
return None
async def upload_image(self, image_bytes: bytes, filename: str) -> str | None:
url = f"{self.homeserver}/_matrix/media/v3/upload?filename={filename}"
headers = {**self.headers, "Content-Type": "image/jpeg"}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=image_bytes) as resp:
if resp.status < 300:
data = await resp.json()
mxc_uri = data.get("content_uri")
logger.info("Uploaded image: %s", mxc_uri)
return mxc_uri
body = await resp.text()
logger.error("Image upload failed %s: %s", resp.status, body)
except Exception:
logger.exception("Image upload failed")
return None
async def send_image(self, mxc_uri: str, filename: str, size: int) -> dict | None:
txn_id = str(int(time.time() * 1000))
url = f"{self.homeserver}/_matrix/client/r0/rooms/{self.room_id}/send/m.room.message/{txn_id}"
body = {
"msgtype": "m.image",
"body": filename,
"url": mxc_uri,
"info": {"mimetype": "image/jpeg", "size": size},
}
return await self._request("PUT", url, json=body)
async def send_text(self, plain: str, html: str) -> dict | None:
txn_id = str(int(time.time() * 1000) + 1)
url = f"{self.homeserver}/_matrix/client/r0/rooms/{self.room_id}/send/m.room.message/{txn_id}"
body = {
"msgtype": "m.text",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
}
return await self._request("PUT", url, json=body)
async def _sleep(seconds: float):
import asyncio
await asyncio.sleep(seconds)
```
**Step 4: Create main.py - MQTT listener and event processor**
```
compose/hf-pdocker-01/frigate-notify/app/main.py
```
```python
import asyncio
import json
import logging
import os
import signal
import sys
import time
from collections import defaultdict
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread
import anthropic
import paho.mqtt.client as mqtt
from vision import analyze_snapshot
from matrix_client import MatrixClient
# --- Configuration ---
MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000")
MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008")
MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"]
MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"]
HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199"))
ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"}
MIN_SCORE = 0.7
RATE_LIMIT_SECONDS = 60
DEDUP_WINDOW_SECONDS = 30
# --- Logging ---
logging.basicConfig(
level=logging.INFO,
format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}',
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
# --- State ---
recent_events: dict[str, float] = {}
last_notify_per_camera: defaultdict[str, float] = defaultdict(float)
events_processed = 0
last_event_time: str | None = None
mqtt_connected = False
# --- Health endpoint ---
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
body = json.dumps({
"status": "ok",
"mqtt_connected": mqtt_connected,
"events_processed": events_processed,
"last_event": last_event_time,
})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # suppress access logs
def start_health_server():
server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info("Health server started on port %d", HEALTH_PORT)
# --- Snapshot fetching ---
async def fetch_snapshot(event_id: str) -> bytes | None:
import aiohttp
url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.read()
logger.info("Fetched snapshot for %s: %d bytes", event_id, len(data))
return data
logger.warning("Snapshot fetch failed for %s: HTTP %d", event_id, resp.status)
except Exception:
logger.exception("Snapshot fetch error for %s", event_id)
return None
# --- Event processing ---
async def process_event(event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient):
global events_processed, last_event_time
event_type = event_data.get("type")
if event_type != "end":
return
after = event_data.get("after", {})
event_id = after.get("id", "")
camera = after.get("camera", "unknown")
label = after.get("label", "")
score = after.get("top_score", after.get("score", 0))
has_snapshot = after.get("has_snapshot", False)
# --- Filtering ---
if label not in ALLOWED_LABELS:
return
if score < MIN_SCORE:
return
if not has_snapshot:
return
# Dedup
now = time.time()
if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS:
return
recent_events[event_id] = now
# Rate limit per camera
if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS:
logger.info("Rate limited: %s on %s", label, camera)
return
last_notify_per_camera[camera] = now
# Cleanup old dedup entries
cutoff = now - DEDUP_WINDOW_SECONDS * 2
for eid in [k for k, v in recent_events.items() if v < cutoff]:
del recent_events[eid]
logger.info("Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score)
# --- Fetch snapshot ---
snapshot = await fetch_snapshot(event_id)
# --- AI analysis ---
description = None
if snapshot:
description = analyze_snapshot(claude, snapshot)
if not description:
description = f"{label.capitalize()} detected on {camera}."
# --- Format message ---
score_pct = int(score * 100)
ts = time.strftime("%I:%M:%S %p", time.localtime())
date_str = time.strftime("%b %d, %Y", time.localtime())
frigate_link = f"{FRIGATE_URL}/review?id={event_id}"
plain = f"{camera} - {label.capitalize()} - {score_pct}%\n{description}\n\nView in Frigate: {frigate_link}\n{ts} - {date_str}"
html = (
f"<b>{camera}</b> &middot; {label.capitalize()} &middot; {score_pct}%<br/>"
f"{description}<br/><br/>"
f'<a href="{frigate_link}">View in Frigate</a><br/>'
f"<small>{ts} &middot; {date_str}</small>"
)
# --- Send to Matrix ---
if snapshot:
filename = f"{camera}_{event_id}.jpg"
mxc_uri = await matrix.upload_image(snapshot, filename)
if mxc_uri:
await matrix.send_image(mxc_uri, filename, len(snapshot))
await matrix.send_text(plain, html)
events_processed += 1
last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
logger.info("Notification sent for %s on %s", label, camera)
# --- MQTT ---
def create_mqtt_client(loop: asyncio.AbstractEventLoop, claude: anthropic.Anthropic, matrix: MatrixClient):
global mqtt_connected
def on_connect(client, userdata, flags, reason_code, properties):
global mqtt_connected
mqtt_connected = True
client.subscribe("frigate/events")
logger.info("MQTT connected, subscribed to frigate/events")
def on_disconnect(client, userdata, flags, reason_code, properties):
global mqtt_connected
mqtt_connected = False
logger.warning("MQTT disconnected: %s", reason_code)
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload)
asyncio.run_coroutine_threadsafe(process_event(payload, claude, matrix), loop)
except json.JSONDecodeError:
logger.warning("Invalid JSON on %s", msg.topic)
except Exception:
logger.exception("Error processing MQTT message")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
return client
# --- Main ---
def main():
logger.info("Starting frigate-notify")
claude = anthropic.Anthropic()
matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID)
loop = asyncio.new_event_loop()
loop_thread = Thread(target=loop.run_forever, daemon=True)
loop_thread.start()
start_health_server()
mqtt_client = create_mqtt_client(loop, claude, matrix)
mqtt_client.connect(MQTT_HOST, MQTT_PORT)
def shutdown(signum, frame):
logger.info("Shutting down...")
mqtt_client.disconnect()
loop.call_soon_threadsafe(loop.stop)
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
mqtt_client.loop_forever()
if __name__ == "__main__":
main()
```
**Step 5: Commit**
```bash
git add compose/hf-pdocker-01/frigate-notify/app/
git commit -m "feat: add frigate-notify Python application
MQTT listener for Frigate events with Claude Vision analysis
and Matrix notification delivery."
```
---
### Task 3: Create Dockerfile and docker-compose.yml
**Files:**
- Create: `compose/hf-pdocker-01/frigate-notify/Dockerfile`
- Create: `compose/hf-pdocker-01/frigate-notify/docker-compose.yml`
- Create: `compose/hf-pdocker-01/frigate-notify/.env`
**Step 1: Create Dockerfile**
```
compose/hf-pdocker-01/frigate-notify/Dockerfile
```
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
EXPOSE 5199
CMD ["python", "-u", "main.py"]
```
**Step 2: Create docker-compose.yml**
```
compose/hf-pdocker-01/frigate-notify/docker-compose.yml
```
```yaml
services:
frigate-notify:
build:
context: .
dockerfile: Dockerfile
container_name: frigate-notify
restart: unless-stopped
network_mode: host
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN}
- MATRIX_ROOM_ID=${MATRIX_ROOM_ID}
- FRIGATE_URL=http://192.168.86.192:5000
- MQTT_HOST=192.168.86.192
- MQTT_PORT=1883
- MATRIX_HOMESERVER=http://192.168.86.192:8008
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5199/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
```
**Step 3: Create .env file**
```
compose/hf-pdocker-01/frigate-notify/.env
```
```
ANTHROPIC_API_KEY=<get from existing netclaw .env or vault>
MATRIX_ACCESS_TOKEN=<from Task 1 step 2>
MATRIX_ROOM_ID=<from Task 1 step 3>
```
Note: Get the ANTHROPIC_API_KEY from the existing netclaw deployment:
```bash
ssh glyptic@192.168.86.100 "cat /mnt/docker-data/compose/hf-pdocker-02/netclaw/.env | grep ANTHROPIC"
```
**Step 4: Commit**
```bash
git add compose/hf-pdocker-01/frigate-notify/Dockerfile
git add compose/hf-pdocker-01/frigate-notify/docker-compose.yml
# Do NOT commit .env (contains secrets)
git commit -m "feat: add frigate-notify Docker configuration
Standalone Compose service on hf-pdocker-01 with host networking,
256M memory limit, health check on port 5199."
```
---
### Task 4: Build and deploy on hf-pdocker-01
This task uses the docker-swarm-ops agent to SSH to hf-pdocker-01.
**Step 1: Copy files to hf-pdocker-01**
SCP the entire `compose/hf-pdocker-01/frigate-notify/` directory to `/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/` on hf-pdocker-01.
**Step 2: Build the container**
```bash
cd /mnt/docker-data/compose/hf-pdocker-01/frigate-notify
docker compose build
```
**Step 3: Start the container**
```bash
docker compose up -d
```
**Step 4: Verify container is running**
```bash
docker ps --filter name=frigate-notify --format "table {{.Names}}\t{{.Status}}"
```
Expected: `frigate-notify Up X seconds (health: starting)`
**Step 5: Check logs for successful startup**
```bash
docker logs frigate-notify --tail 20
```
Expected: Health server started, MQTT connected, subscribed to frigate/events.
**Step 6: Verify health endpoint**
```bash
curl -s http://localhost:5199/health | python3 -m json.tool
```
Expected: `{"status": "ok", "mqtt_connected": true, "events_processed": 0, "last_event": null}`
---
### Task 5: Add monitoring and verify end-to-end
**Files:**
- Modify: `stacks/monitoring/prometheus.yml` - add health probe target
**Step 1: Add blackbox probe target**
In `stacks/monitoring/prometheus.yml`, add to the `http-pdocker01` targets list:
```yaml
- http://192.168.86.192:5199/health # Frigate Notify
```
**Step 2: Deploy updated prometheus config**
SCP to hf-pdocker-01, copy to NAS, hot-reload Prometheus:
```bash
sudo cp /data/monitoring/prometheus.yml /mnt/docker-data/configs/prometheus/prometheus.yml
# Reload inside container
docker exec $(docker ps -q -f name=monitoring_prometheus) wget -qO- --post-data='' http://localhost:9090/-/reload
```
**Step 3: Trigger a test detection**
Walk in front of a camera, or wait for an organic detection event. Check logs:
```bash
docker logs frigate-notify --tail 30 -f
```
Expected: Event processing log, snapshot fetch, vision analysis, Matrix notification sent.
**Step 4: Verify Matrix message received**
Check `#video-alerts:hobbs.farm` in Matrix for the snapshot image and AI-generated description.
**Step 5: Store credentials in vault**
Use vault-credential-manager agent to store the Frigate bot credentials.
**Step 6: Commit monitoring update**
```bash
git add stacks/monitoring/prometheus.yml
git commit -m "feat: add frigate-notify to Prometheus blackbox probes"
```
---
### Task 6: Update documentation and memory
**Step 1: Update MEMORY.md**
Add entry for frigate-notify service:
- Container location, port, purpose
- Matrix bot identity and room
- Key config notes
**Step 2: Update CLAUDE.md service distribution**
Add frigate-notify to hf-pdocker-01 Compose services list.
**Step 3: Update alert-manager agent**
Add frigate-notify to the service list and probe targets documentation.
**Step 4: Commit documentation updates**
```bash
git add -A
git commit -m "docs: add frigate-notify to infrastructure documentation"
```
@@ -0,0 +1,182 @@
# Farm Manager — Design Document
**Date**: 2026-03-05
**Status**: Approved
## Overview
A web-based dashboard for managing Docker services across the 3-node homelab cluster. Provides full control (start/stop/restart/logs/pull) with service grouping for bulk operations.
## Architecture
Three components:
1. **Dashboard** — Static HTML/CSS/JS served by the API server. Dark theme, single page with service cards, group management, and slide-out log viewer.
2. **API Server** — Python FastAPI on hf-pdocker-01:8888. Serves frontend, proxies Docker commands to node agents, manages group definitions in JSON.
3. **Node Agent** — Python FastAPI on each node:8889. Mounts Docker socket, exposes REST API for container operations. Auto-detects Swarm vs Compose containers.
```
Browser → Dashboard (hf-pdocker-01:8888)
↓
API Server
↙ ↓ ↘
Agent Agent Agent
(01:8889) (02:8889) (bart:8889)
↓ ↓ ↓
Docker Docker Docker
Socket Socket Socket
```
## API Endpoints
### API Server (hf-pdocker-01:8888)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/nodes` | List all nodes with health status |
| GET | `/api/services` | All containers across all nodes |
| POST | `/api/services/{node}/{container}/start` | Start container |
| POST | `/api/services/{node}/{container}/stop` | Stop container |
| POST | `/api/services/{node}/{container}/restart` | Restart container |
| GET | `/api/services/{node}/{container}/logs?tail=100` | Stream logs |
| POST | `/api/services/{node}/{container}/pull` | Pull latest image |
| GET | `/api/groups` | List all groups |
| POST | `/api/groups` | Create group |
| PUT | `/api/groups/{id}` | Edit group |
| DELETE | `/api/groups/{id}` | Delete group |
| POST | `/api/groups/{id}/start` | Start all services in group |
| POST | `/api/groups/{id}/stop` | Stop all services in group |
| POST | `/api/groups/{id}/restart` | Restart all services in group |
### Node Agent (each node:8889)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/containers` | List all containers with status |
| POST | `/containers/{id}/start` | Start |
| POST | `/containers/{id}/stop` | Stop |
| POST | `/containers/{id}/restart` | Restart |
| GET | `/containers/{id}/logs?tail=100` | Get logs |
| POST | `/containers/{id}/pull` | Pull latest image |
| GET | `/health` | Agent health check |
## Frontend UI
- **Sidebar**: Node list with health dots, group list with expand/collapse, "All Services" view, group management button
- **Main area**: Grid of service cards (name, status badge, node, image, uptime, action buttons)
- **Log viewer**: Slide-out right panel, last 200 lines, auto-scroll
- **Group actions**: Toolbar with Start All / Stop All / Restart All, confirmation modal for destructive actions
- **Group editor**: Modal with name field and checkbox service list
- **Styling**: Dark theme, custom CSS variables, no framework
## Data Storage
### Groups (`/mnt/docker-data/configs/farm-manager/groups.json`)
```json
{
"groups": [
{
"id": "ai-agents",
"name": "AI Agents",
"services": [
{"node": "hf-pdocker-01", "container": "ai-agents_margaret"},
{"node": "hf-pdocker-01", "container": "ai-agents_spirit"},
{"node": "hf-pdocker-01", "container": "ai-agents_bob"},
{"node": "hf-pdocker-01", "container": "ai-agents_clippy"}
]
}
]
}
```
### Node config (`config.json`)
```json
{
"nodes": [
{"name": "hf-pdocker-01", "host": "192.168.86.192", "agent_port": 8889},
{"name": "hf-pdocker-02", "host": "192.168.86.100", "agent_port": 8889},
{"name": "bart", "host": "192.168.86.167", "agent_port": 8889}
]
}
```
### Default Groups
| Group | Services |
|-------|----------|
| AI Agents | margaret, spirit, bob, clippy |
| Smart Home | homeassistant, esphome, mosquitto |
| Monitoring | grafana, prometheus, alertmanager, influxdb, blackbox-exporter |
| Media | jellyfin, sonarr, radarr, sabnzbd, nzbhydra2, jellyseerr |
| Matrix | synapse, synapse-db, wellknown |
| Guacamole | guacamole, guacd, guacamole-db |
| Dev Tools | gitea, gitea-db, semaphore, semaphore-db, wikijs, wikijs-db |
| Security | vaultwarden, trivy, openvas |
| Network | traefik, adguard, unifi, unifi-db |
| Frigate | frigate, frigate-notify |
## Deployment
### Node Agent (all 3 nodes)
- Path: `/mnt/docker-data/compose/{node}/farm-agent/docker-compose.yml`
- Image: built once, pushed to registry at 127.0.0.1:5050
- Mounts: `/var/run/docker.sock`
- Port: 8889
- mem_limit: 128M
### API Server + Dashboard (hf-pdocker-01)
- Path: `/mnt/docker-data/compose/hf-pdocker-01/farm-manager/docker-compose.yml`
- Port: 8888
- Config: `/mnt/docker-data/configs/farm-manager/`
- mem_limit: 256M
### Swarm Handling
Node agent auto-detects Swarm-managed containers and uses `docker service` API for Swarm services, `docker` API for Compose containers.
## Repo
- Gitea: `jeremy/farm-manager` (git.hobbs.farm)
- Structure:
```
farm-manager/
├── agent/
│ ├── main.py
│ ├── docker_ops.py
│ ├── requirements.txt
│ └── Dockerfile
├── server/
│ ├── main.py
│ ├── routes/
│ ├── static/
│ │ ├── index.html
│ │ ├── style.css
│ │ └── app.js
│ ├── requirements.txt
│ └── Dockerfile
├── tests/
│ ├── test_agent.py
│ ├── test_server.py
│ └── conftest.py
├── config.json
├── groups.json
└── README.md
```
## Testing
| Layer | Scope | Tool |
|-------|-------|------|
| Unit | Agent Docker operations, group CRUD, API proxy logic | pytest + pytest-asyncio |
| Integration | API server ↔ mock agent, group persistence | pytest + httpx TestClient |
| Contract | Agent response schemas match dashboard expectations | pydantic models |
## Monitoring
- Prometheus blackbox probes: `:8888/health` and `:8889/health` on each node
- No auth (internal LAN only)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,242 @@
# Frigate-Notify v2 Design
## Summary
Evolve frigate-notify from a simple MQTT-to-Matrix alert forwarder into an intelligent notification system with known-object recognition, a web UI for managing identities and reviewing events, and configurable vision backends (local Ollama on a 3090 GPU or Claude Haiku cloud API).
## Goals
1. **Reduce noise** — stop alerting on known family members, vehicles, and pets
2. **Learn from real events** — tag objects from Frigate detection snapshots to build a recognition database over time
3. **Local AI processing** — use a local 3090 GPU (192.168.86.196) for CLIP embeddings and vision LLM, reducing cloud API costs
4. **Web interface** — standalone app for managing known objects, reviewing events, and configuring settings
5. **Farm Manager integration** — sidebar link in Farm Manager pointing to the frigate-notify UI
## Architecture
```
Frigate NVR ──MQTT──> Mosquitto Broker
|
v
frigate-notify v2 (:5199)
+-------------------------+
| MQTT Listener |
| Event Processor |
| Web UI (HTML/CSS/JS) |
| REST API (FastAPI) |
| SQLite DB |
+------+------+------+-----+
| | |
+------------+ | +------------+
v v v
clip-service (:5200) Ollama (:11434) Claude Haiku API
192.168.86.196 192.168.86.196 cloud
CLIP embeddings Vision LLM Vision fallback
Scene descriptions Scene descriptions
| |
+-------+-----------+
v
GPU Machine (RTX 3090, 24GB VRAM)
frigate-notify v2
|
v
Matrix API
#video-alerts room
(unknown objects only)
Farm Manager (:8888) ──sidebar link──> frigate-notify (:5199)
```
## Known Object Recognition Pipeline
Each Frigate event goes through this flow:
```
Event arrives (MQTT, type="end")
-> Fetch snapshot from Frigate API
-> Generate CLIP embedding via clip-service (192.168.86.196:5200)
-> Compare against stored embeddings in SQLite
-> High confidence match (>0.85): "known" -- log quietly, no alert
-> Low confidence (<0.60): "unknown" -- send Matrix alert
-> Ambiguous (0.60-0.85): send snapshot + reference images to Vision LLM
-> LLM confirms match: "known" -- log quietly
-> LLM says no match: "unknown" -- send Matrix alert
```
Scene description (the AI-generated text in notifications) is handled separately and is configurable to use either Haiku or Ollama vision model.
### Fallback Chain
1. clip-service down: skip embedding matching, send all events to vision LLM
2. Ollama down: fall back to Claude Haiku for everything
3. Haiku down: structured text description ("Person detected on back_porch")
### Known Object Types
- **People** -- tagged by name, matched by face/body appearance
- **Vehicles** -- tagged by description (e.g., "blue F-150"), matched by shape/color
- **Pets** -- tagged by name, matched by appearance
### Learning Flow
1. Events stream in and show up in the web UI's "Recent Events" feed
2. User taps an event and chooses "Tag as known" -> enters name/description -> selects type (person/vehicle/pet)
3. System stores the CLIP embedding from that snapshot tied to that identity
4. Multiple tagged snapshots of the same identity improve matching accuracy over time
5. User can also "Confirm" or "Reject" the system's auto-matches to refine it
### Notification Behavior for Known Objects
- **Known objects**: logged quietly in the web UI, no Matrix notification
- **Unknown objects**: full Matrix alert with snapshot + AI description (existing behavior)
## Web UI
Served at `:5199`, vanilla HTML/CSS/JS (consistent with Farm Manager). Same container as the API and event processor.
### Pages
| Page | Purpose |
|------|---------|
| Dashboard | Live feed of recent events. Each shows snapshot thumbnail, camera, label, score, known/unknown status, AI description. Filter by camera, label, status. |
| Known Objects | Grid of all tagged identities. Click to see all associated snapshots, edit name, delete. Organized by type (people/vehicles/pets). |
| Review Queue | Unreviewed events needing tagging. Quick-action buttons: "Tag as known", "Dismiss", "Confirm match". |
| Settings | Vision backend toggle (Haiku/Ollama/both), confidence thresholds, rate limiting, camera enable/disable, Matrix notification preferences. |
## REST API
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/events` | GET | List recent events, filterable |
| `/api/events/{id}` | GET | Single event detail |
| `/api/events/{id}/tag` | POST | Tag event as known object |
| `/api/events/{id}/dismiss` | POST | Dismiss event |
| `/api/known-objects` | GET | List all known identities |
| `/api/known-objects/{id}` | GET/PUT/DELETE | Manage a known identity |
| `/api/settings` | GET/PUT | Read/update configuration |
| `/api/stats` | GET | Event counts, match rates |
| `/health` | GET | Existing health endpoint (preserved) |
## Data Model
SQLite database at `/mnt/docker-data/configs/frigate-notify/frigate-notify.db`
### Tables
```sql
known_objects
id INTEGER PRIMARY KEY
name TEXT NOT NULL -- "Jeremy", "Blue F-150", "Max the dog"
type TEXT NOT NULL -- "person", "vehicle", "pet"
action TEXT DEFAULT "quiet" -- "quiet" (log only) | "suppress" | "alert"
created_at TIMESTAMP
updated_at TIMESTAMP
embeddings
id INTEGER PRIMARY KEY
known_object_id INTEGER FK -> known_objects
embedding BLOB -- CLIP vector (float32 array)
snapshot_url TEXT -- Frigate snapshot URL for reference
thumbnail BLOB -- Small JPEG for UI display
created_at TIMESTAMP
events
id INTEGER PRIMARY KEY
frigate_event_id TEXT UNIQUE -- Frigate's event ID
camera TEXT
label TEXT -- person/car/dog/cat/bird
score REAL
snapshot BLOB -- JPEG thumbnail for UI
embedding BLOB -- CLIP vector for this event
match_status TEXT -- "known", "unknown", "ambiguous", "unreviewed"
matched_object_id INTEGER FK -> known_objects (nullable)
match_confidence REAL
ai_description TEXT -- Scene description from Haiku/Ollama
notified BOOLEAN DEFAULT 0 -- Was a Matrix alert sent?
created_at TIMESTAMP
settings
key TEXT PRIMARY KEY
value TEXT -- JSON-encoded value
```
### Storage Estimate
~50 events/day x 50KB thumbnail = ~2.5MB/day. With 90-day retention and auto-pruning, DB stays under 500MB.
## Ollama & CLIP Integration
### clip-service (new sidecar container)
Runs on 192.168.86.196 alongside Ollama. Lightweight FastAPI app that loads the CLIP model and exposes embedding endpoints.
```
192.168.86.196 (GPU machine, RTX 3090)
+-- Ollama (:11434) -- vision LLM (llava:13b)
+-- clip-service (:5200) -- CLIP embedding generation
POST /embed {image: base64} -> {embedding: [float...]}
POST /similarity {embedding1, embedding2} -> {score: 0.0-1.0}
```
- Uses `open_clip` library with ViT-L/14 model
- GPU-accelerated, ~2GB VRAM, <50ms per image
- Stateless -- frigate-notify stores the embeddings
### Ollama Vision Models
Pull on 192.168.86.196:
- `llava:13b` -- scene description + ambiguous object matching
- ~7.6GB free VRAM after CLIP (3090 has 24GB total)
### Vision Backend Configuration
User-configurable in Settings page:
- **Scene description backend**: Haiku / Ollama / Ollama-primary-Haiku-fallback
- **Recognition backend**: CLIP+Ollama / CLIP+Haiku / CLIP-only
## Tech Stack
- **Python + FastAPI** -- replaces current raw HTTP server, serves API + static files
- **SQLite** -- data storage (known objects, embeddings, events, settings)
- **Vanilla HTML/CSS/JS** -- web UI, consistent with Farm Manager
- **paho-mqtt** -- MQTT listener (kept from v1)
- **aiohttp** -- Matrix client (kept from v1)
- **open_clip** -- CLIP service sidecar on GPU machine
- **numpy** -- embedding similarity calculations
## Deployment
- `frigate-notify` container: on same node as Frigate (hf-pdocker-01 when back)
- `clip-service` container: on 192.168.86.196 (GPU machine), compose file on NAS
- Ollama: already running on 192.168.86.196
### Compose Location
- frigate-notify: `/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/`
- clip-service: `/mnt/docker-data/compose/gpu-machine/clip-service/`
### Farm Manager Link
Add "Frigate Alerts" entry to Farm Manager sidebar config pointing to `http://<frigate-notify-host>:5199`. No code coupling.
## Migration from v1
All existing v1 functionality preserved:
- MQTT listener and event filtering (labels, scores, dedup, rate limiting)
- Frigate snapshot fetching
- Claude Haiku vision analysis
- Matrix notifications (image + text)
- Health endpoint at `/health`
New features layer on top. The app can run in "v1 mode" (no CLIP service, no known objects) if the GPU machine is offline.
## Decisions
- **Standalone app with Farm Manager link** over built-in: independent deployment, no coupling
- **Tag from events** over upload reference photos: uses real camera footage, less setup
- **Quiet log for known objects** over full suppress: can review routine activity without being interrupted
- **Hybrid CLIP + Vision LLM** over either alone: fast matching for clear cases, LLM accuracy for ambiguous
- **User-configurable vision backend**: flexibility to use local or cloud, switch as needed
- **SQLite** over PostgreSQL: single-file, no extra container, sufficient for this data volume
- **Vanilla JS** over React/Vue: consistent with Farm Manager, no build step, simple
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,428 @@
# hf-pdocker-01 Rebuild — Phase 1 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rebuild hf-pdocker-01 from fresh Ubuntu 24.04 to a fully operational Swarm manager with Frigate NVR, Farm Agent, and Frigate-Notify v2.
**Architecture:** Fresh Ubuntu 24.04 on NVMe, Docker CE, NFS-backed storage from Synology NAS, 5TB USB disk for Frigate recordings. Docker Swarm re-initialized as single manager with hf-pdocker-02 and bart rejoined as workers. Services deployed via Compose files already on NAS.
**Tech Stack:** Ubuntu 24.04, Docker CE, Docker Swarm, NFS v3, Intel VA-API/OpenVINO, Python 3.12 (containers)
**Access:** SSH to hf-pdocker-01 (192.168.86.192), user `glyptic`, password `&Rip8a115`
**Important:** All tasks execute via SSH from the WSL environment. Use the `docker-swarm-ops` agent for all remote operations.
---
### Task 1: Install System Prerequisites
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Install base packages**
```bash
sudo apt-get update
sudo apt-get install -y nfs-common curl jq git rsync
```
**Step 2: Install Docker CE**
```bash
# Add Docker GPG key and repo
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
**Step 3: Add glyptic to docker group**
```bash
sudo usermod -aG docker glyptic
```
**Step 4: Install Intel VA-API drivers for Frigate OpenVINO**
```bash
sudo apt-get install -y intel-media-va-driver-non-free vainfo
```
**Step 5: Verify**
```bash
docker --version # Expect: Docker version 28.x
docker compose version # Expect: Docker Compose version v2.x
vainfo # Expect: VA-API device listed (iHD driver, HD 630)
ls /dev/dri/ # Expect: card0, renderD128
```
---
### Task 2: Configure System Settings
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Set vm.swappiness to 10 (cluster standard)**
```bash
echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.conf
sudo sysctl vm.swappiness=10
```
**Step 2: Verify**
```bash
sysctl vm.swappiness # Expect: vm.swappiness = 10
```
---
### Task 3: Configure NFS Mounts
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Create mount points**
```bash
sudo mkdir -p /mnt/docker-data /mnt/infrastructure
```
**Step 2: Add NFS entries to fstab**
```bash
echo '192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3 0 0' | sudo tee -a /etc/fstab
echo '192.168.86.30:/volume1/Infrastructure /mnt/infrastructure nfs rw,hard,intr,_netdev 0 0' | sudo tee -a /etc/fstab
```
**Step 3: Mount and verify**
```bash
sudo mount -a
ls /mnt/docker-data/configs/ # Expect: frigate, frigate-notify, etc.
ls /mnt/infrastructure/margaret/ # Expect: openclaw.json, credentials/, etc.
```
---
### Task 4: Mount 5TB Frigate Recordings Disk
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Identify the disk and verify existing data**
```bash
sudo blkid /dev/sda1 # Check filesystem type (expect ext4)
sudo mkdir -p /mnt/frigate-recordings
sudo mount /dev/sda1 /mnt/frigate-recordings
ls /mnt/frigate-recordings/ # Expect: frigate recording directories
```
**Step 2: Add to fstab using UUID for reliability**
```bash
# Get UUID
UUID=$(sudo blkid -s UUID -o value /dev/sda1)
echo "UUID=$UUID /mnt/frigate-recordings ext4 defaults,_netdev 0 2" | sudo tee -a /etc/fstab
```
**Step 3: Verify**
```bash
df -h /mnt/frigate-recordings # Expect: ~4.5T disk
```
---
### Task 5: Re-initialize Docker Swarm
**Target:** hf-pdocker-01 (192.168.86.192), then hf-pdocker-02 and bart
**Step 1: Initialize Swarm on hf-pdocker-01**
```bash
# On hf-pdocker-01:
docker swarm init --advertise-addr 192.168.86.192
```
Save the worker join token from the output.
**Step 2: Clean up stale Swarm state on workers**
```bash
# On hf-pdocker-02 (192.168.86.100):
docker swarm leave --force
# On bart (192.168.86.167):
docker swarm leave --force
```
**Step 3: Rejoin workers**
```bash
# On hf-pdocker-02:
docker swarm join --token <WORKER_TOKEN> 192.168.86.192:2377
# On bart:
docker swarm join --token <WORKER_TOKEN> 192.168.86.192:2377
```
**Step 4: Verify cluster**
```bash
# On hf-pdocker-01:
docker node ls # Expect: 3 nodes, hf-pdocker-01 as Leader/Manager, others as Workers
```
**Step 5: Recreate overlay networks**
```bash
# On hf-pdocker-01:
docker network create --driver overlay --attachable traefik-public
```
---
### Task 6: Deploy Frigate NVR
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Update Frigate config MQTT address**
The Frigate config at `/mnt/docker-data/configs/frigate/config/config.yml` has MQTT pointed at `192.168.86.192:1883` (the old hf-pdocker-01 address). Mosquitto is currently running on bart (192.168.86.167) as a failover service.
```bash
# On hf-pdocker-01, edit the config to point MQTT to bart:
sed -i 's/host: 192.168.86.192/host: 192.168.86.167/' /mnt/docker-data/configs/frigate/config/config.yml
```
Verify the change:
```bash
grep -A2 "^mqtt:" /mnt/docker-data/configs/frigate/config/config.yml
# Expect: host: 192.168.86.167
```
**Step 2: Deploy Frigate**
```bash
cd /mnt/docker-data/compose/hf-pdocker-01/frigate
docker compose up -d
```
**Step 3: Verify**
```bash
docker ps --filter name=frigate --format "table {{.Names}}\t{{.Status}}" # Expect: Up
curl -s http://localhost:5000/api/version # Expect: Frigate version response
curl -s http://localhost:5000/api/stats | jq '.detectors' # Expect: openvino detector ~10ms inference
```
Wait 1-2 minutes for cameras to initialize, then check:
```bash
curl -s http://localhost:5000/api/stats | jq '.cameras | keys' # Expect: list of camera names
```
---
### Task 7: Deploy Farm Agent
**Target:** hf-pdocker-01 (192.168.86.192)
The existing farm-agent compose references `127.0.0.1:5050` (local registry, which is gone). We'll build the image directly from Gitea instead.
**Step 1: Clone farm-manager repo and build agent image**
```bash
cd /tmp
git clone http://192.168.86.100:3003/jeremy/farm-manager.git
cd farm-manager/agent
docker build -t farm-agent:latest .
```
**Step 2: Update compose file to use local image**
```bash
# Edit the compose file on NAS
sed -i 's|image: 127.0.0.1:5050/farm-agent:latest|image: farm-agent:latest|' /mnt/docker-data/compose/hf-pdocker-01/farm-agent/docker-compose.yml
```
**Step 3: Deploy**
```bash
cd /mnt/docker-data/compose/hf-pdocker-01/farm-agent
docker compose up -d
```
**Step 4: Verify**
```bash
docker ps --filter name=farm-agent --format "table {{.Names}}\t{{.Status}}"
curl -s http://localhost:8889/health # Expect: healthy response
```
---
### Task 8: Deploy Frigate-Notify v2
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Clone frigate-notify repo**
```bash
cd /tmp
git clone http://192.168.86.100:3003/jeremy/frigate-notify.git
```
**Step 2: Build the image**
```bash
cd /tmp/frigate-notify
docker build -t frigate-notify:latest .
```
**Step 3: Create .env file with secrets**
The compose file needs `ANTHROPIC_API_KEY`, `MATRIX_ACCESS_TOKEN`, and `MATRIX_ROOM_ID`. Retrieve these from Vaultwarden or use known values.
Known values from MEMORY.md:
- `MATRIX_ACCESS_TOKEN`: `syt_ZnJpZ2F0ZQ_uaKUheedpkQBqtCaldsa_2ifSTC` (bot: @frigate:hobbs.farm)
- `MATRIX_ROOM_ID`: `!MTsFCQjnmLUNFGJePz:hobbs.farm`
- `ANTHROPIC_API_KEY`: retrieve from vault or from bart's ai-agents .env
```bash
cat > /mnt/docker-data/compose/hf-pdocker-01/frigate-notify/.env << 'EOF'
ANTHROPIC_API_KEY=<retrieve from vault>
MATRIX_ACCESS_TOKEN=syt_ZnJpZ2F0ZQ_uaKUheedpkQBqtCaldsa_2ifSTC
MATRIX_ROOM_ID=!MTsFCQjnmLUNFGJePz:hobbs.farm
EOF
```
**Step 4: Update compose to use built image instead of build context, and fix IPs**
The compose file on NAS references `build: context: .` which won't work from NAS path. Update to use the locally built image. Also update MQTT and Matrix homeserver IPs since those services are on bart now.
Updated compose should have:
- `image: frigate-notify:latest` (instead of `build:`)
- `FRIGATE_URL=http://localhost:5000` (Frigate is now local on same host)
- `MQTT_HOST=192.168.86.167` (Mosquitto on bart)
- `MATRIX_HOMESERVER=http://192.168.86.167:8008` (Synapse on bart)
**Step 5: Deploy**
```bash
cd /mnt/docker-data/compose/hf-pdocker-01/frigate-notify
docker compose up -d
```
**Step 6: Verify**
```bash
docker ps --filter name=frigate-notify --format "table {{.Names}}\t{{.Status}}"
curl -s http://localhost:5199/health # Expect: healthy
```
---
### Task 9: Restore Cron Jobs
**Target:** hf-pdocker-01 (192.168.86.192)
**Step 1: Verify scripts are accessible on NAS**
```bash
ls -la /mnt/docker-data/scripts/sync-frigate-to-nas.sh
ls -la /mnt/docker-data/scripts/cleanup-frigate-media.sh
cat /mnt/docker-data/scripts/sync-frigate-to-nas.sh
cat /mnt/docker-data/scripts/cleanup-frigate-media.sh
```
**Step 2: Make scripts executable**
```bash
chmod +x /mnt/docker-data/scripts/sync-frigate-to-nas.sh
chmod +x /mnt/docker-data/scripts/cleanup-frigate-media.sh
```
**Step 3: Install crontab entries**
```bash
(crontab -l 2>/dev/null; echo "0 2 * * * /mnt/docker-data/scripts/sync-frigate-to-nas.sh") | crontab -
(crontab -l 2>/dev/null; echo "0 3 * * 0 /mnt/docker-data/scripts/cleanup-frigate-media.sh") | crontab -
```
**Step 4: Verify**
```bash
crontab -l # Expect: both cron entries listed
```
---
### Task 10: Monitoring Integration
**Target:** hf-pdocker-01 + hf-pdocker-02 (Prometheus)
**Step 1: Deploy Swarm global services (Telegraf + Node-exporter)**
On hf-pdocker-01, deploy the monitoring stacks that run globally:
```bash
# Check if the stack files exist on NAS
ls /mnt/docker-data/compose/hf-pdocker-01/ # Look for telegraf, node-exporter stacks
```
If Telegraf and Node-exporter were Swarm stacks, redeploy them:
```bash
# These were global swarm services - redeploy the stacks
docker stack deploy -c /path/to/telegraf-stack.yml telegraf
docker stack deploy -c /path/to/node-exporter-stack.yml node-exporter
```
**Step 2: Update Prometheus blackbox targets**
On hf-pdocker-02 (192.168.86.100), ensure Prometheus has blackbox probe targets for the new services:
Targets to verify/add:
- `http://192.168.86.192:5000` (Frigate)
- `http://192.168.86.192:5199/health` (Frigate-Notify)
- `http://192.168.86.192:8889/health` (Farm Agent)
**Step 3: Hot-reload Prometheus**
```bash
# On hf-pdocker-02:
curl -X POST http://localhost:9095/-/reload
```
**Step 4: Verify in Grafana**
Check Grafana at http://192.168.86.100:3001 — verify the new targets show as UP in the Prometheus targets page.
---
## Phase 1 Complete Checklist
After all tasks, verify:
- [ ] Docker running on hf-pdocker-01
- [ ] NFS mount at /mnt/docker-data working
- [ ] 5TB disk mounted at /mnt/frigate-recordings
- [ ] Swarm cluster: 3 nodes (1 manager + 2 workers)
- [ ] Frigate running, cameras streaming, OpenVINO detector working
- [ ] Farm Agent responding on :8889
- [ ] Frigate-Notify v2 healthy on :5199
- [ ] Cron jobs installed for sync + cleanup
- [ ] Prometheus monitoring all new endpoints
- [ ] All blackbox probes green
## Phase 2 (Future)
Migrate failover services back to hf-pdocker-01:
- Traefik (from hf-pdocker-02)
- Monitoring stack (from hf-pdocker-02)
- AdGuard Home (from hf-pdocker-02)
- Home Assistant, ESPHome, Mosquitto (from bart)
- Matrix/Synapse (from bart)
- Guacamole (from bart)
- AI Agents (from bart)
- Farm Manager API (new deploy)
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# =============================================================================
# Frigate Media Cleanup
# Removes recordings, clips, and snapshots older than 90 days from NAS
# Run via cron: 0 3 * * 0 /mnt/docker-data/scripts/cleanup-frigate-media.sh
# =============================================================================
MEDIA_DIR="/mnt/docker-data/media/frigate"
MAX_AGE_DAYS=90
LOG_FILE="/var/log/frigate-cleanup.log"
DRY_RUN=false
if [ "$1" = "--dry-run" ]; then
DRY_RUN=true
fi
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"
}
if [ ! -d "$MEDIA_DIR" ]; then
log "ERROR: $MEDIA_DIR does not exist"
exit 1
fi
log "=== Frigate media cleanup started (max age: ${MAX_AGE_DAYS} days, dry_run: ${DRY_RUN}) ==="
# Get size before cleanup
SIZE_BEFORE=$(du -sh "$MEDIA_DIR" 2>/dev/null | cut -f1)
log "Size before: $SIZE_BEFORE"
# Find and remove old files in recordings/, clips/, exports/
for subdir in recordings clips exports; do
DIR="$MEDIA_DIR/$subdir"
if [ -d "$DIR" ]; then
COUNT=$(find "$DIR" -type f -mtime +${MAX_AGE_DAYS} | wc -l)
log "Found $COUNT files older than ${MAX_AGE_DAYS} days in $subdir/"
if [ "$DRY_RUN" = true ]; then
find "$DIR" -type f -mtime +${MAX_AGE_DAYS} -print | head -20
log "(dry run - no files deleted)"
else
find "$DIR" -type f -mtime +${MAX_AGE_DAYS} -delete
# Remove empty directories left behind
find "$DIR" -type d -empty -delete 2>/dev/null
log "Cleaned $subdir/"
fi
fi
done
# Get size after cleanup
SIZE_AFTER=$(du -sh "$MEDIA_DIR" 2>/dev/null | cut -f1)
log "Size after: $SIZE_AFTER"
log "=== Cleanup complete ==="
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# =============================================================================
# Frigate Clip Sync: USB Drive → NAS
# Copies alert/detection clips and exports to NAS for long-term retention.
# Frigate handles short-term cleanup on USB (7 days).
# The cleanup-frigate-media.sh script handles NAS cleanup (90 days).
# Run via cron: 0 2 * * * /mnt/docker-data/scripts/sync-frigate-to-nas.sh
# =============================================================================
USB_DIR="/mnt/frigate-recordings"
NAS_DIR="/mnt/docker-data/media/frigate"
LOG_FILE="/var/log/frigate-sync.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"
}
# Verify both paths exist
if [ ! -d "$USB_DIR" ]; then
log "ERROR: USB drive not mounted at $USB_DIR"
exit 1
fi
if [ ! -d "$NAS_DIR" ]; then
log "ERROR: NAS not mounted at $NAS_DIR"
exit 1
fi
log "=== Frigate clip sync started ==="
# Sync clips (alert/detection event media)
if [ -d "$USB_DIR/clips" ]; then
log "Syncing clips..."
rsync -av --no-o --no-g "$USB_DIR/clips/" "$NAS_DIR/clips/" >> "$LOG_FILE" 2>&1
log "Clips sync complete"
fi
# Sync exports (user-saved clips)
if [ -d "$USB_DIR/exports" ]; then
log "Syncing exports..."
rsync -av --no-o --no-g "$USB_DIR/exports/" "$NAS_DIR/exports/" >> "$LOG_FILE" 2>&1
log "Exports sync complete"
fi
# Report sizes
USB_CLIPS=$(du -sh "$USB_DIR/clips" 2>/dev/null | cut -f1)
USB_RECORDINGS=$(du -sh "$USB_DIR/recordings" 2>/dev/null | cut -f1)
NAS_CLIPS=$(du -sh "$NAS_DIR/clips" 2>/dev/null | cut -f1)
NAS_EXPORTS=$(du -sh "$NAS_DIR/exports" 2>/dev/null | cut -f1)
log "USB clips: $USB_CLIPS | USB recordings: $USB_RECORDINGS"
log "NAS clips: $NAS_CLIPS | NAS exports: $NAS_EXPORTS"
log "=== Sync complete ==="
+2
View File
@@ -0,0 +1,2 @@
# Environment files containing secrets
**/.env
+30
View File
@@ -0,0 +1,30 @@
version: "3.8"
services:
actual-budget:
image: actualbudget/actual-server:latest
restart: unless-stopped
ports:
- "5006:5006"
volumes:
- /data/configs/actual-budget:/data
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:5006/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
+32
View File
@@ -0,0 +1,32 @@
services:
adguardhome:
image: adguard/adguardhome:latest
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/adguardhome/work:/opt/adguardhome/work
- /mnt/docker-data/configs/adguardhome/conf:/opt/adguardhome/conf
ports:
- "53:53/tcp"
- "53:53/udp"
- "3000:3000/tcp"
- "853:853/tcp"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
+1
View File
@@ -0,0 +1 @@
ANTHROPIC_API_KEY=sk-ant-...
+120
View File
@@ -0,0 +1,120 @@
version: "3.8"
services:
margaret:
image: 127.0.0.1:5050/margaret-openclaw:latest
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
- /mnt/infrastructure/margaret:/home/glyptic/.openclaw
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
spirit:
image: 127.0.0.1:5050/margaret-openclaw:latest
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- OPENCLAW_STATE_DIR=/home/glyptic/.spirit
- OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
- /mnt/infrastructure/spirit:/home/glyptic/.spirit
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
bob:
image: 127.0.0.1:5050/margaret-openclaw:latest
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- OPENCLAW_STATE_DIR=/home/glyptic/.bob
- OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
- /mnt/infrastructure/bob:/home/glyptic/.bob
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
clippy:
image: 127.0.0.1:5050/margaret-openclaw:latest
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- OPENCLAW_STATE_DIR=/home/glyptic/.clippy
- OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
volumes:
- /mnt/infrastructure/clippy:/home/glyptic/.clippy
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
+263
View File
@@ -0,0 +1,263 @@
mqtt:
enabled: true
host: mosquitto
port: 1883
detectors:
coral:
type: cpu
# Intel QuickSync hardware acceleration
ffmpeg:
hwaccel_args: preset-intel-qsv-h264
# Database for event history
database:
path: /media/frigate/frigate.db
# Record settings
record:
enabled: true
retain:
days: 7
mode: motion
alerts:
retain:
days: 14
detections:
retain:
days: 14
objects:
track:
- person
- car
- dog
- cat
- truck
filters:
person:
min_area: 5000
threshold: 0.75
car:
min_area: 10000
threshold: 0.7
# Snapshots
snapshots:
enabled: true
clean_copy: true
retain:
default: 14
mode: motion
# Camera definitions
cameras:
# House Perimeter Cameras
house_north:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.51:554/stream1
roles:
- detect
- record
detect:
width: 1024
height: 768
fps: 5
motion:
mask:
- 0,0,0,100,100,100,100,0
zones:
front_yard:
coordinates: 0,768,1024,768,1024,384,0,384
house_west:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.52:554/stream1
roles:
- detect
- record
detect:
width: 1024
height: 768
fps: 5
house_south:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.53:554/stream1
roles:
- detect
- record
detect:
width: 1024
height: 768
fps: 5
house_east:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.58:554/stream1
roles:
- detect
- record
detect:
width: 960
height: 540
fps: 5
house_south_corner:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.61:554/stream1
roles:
- detect
- record
detect:
width: 1280
height: 960
fps: 5
objects:
track:
- person
- car
- dog
house_south_yard:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.62:554/stream1
roles:
- detect
- record
detect:
width: 1280
height: 960
fps: 5
back_porch:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.63:554/stream1
roles:
- detect
- record
detect:
width: 1280
height: 960
fps: 5
# Garage & Driveway
garage_2:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.64:554/stream1
roles:
- detect
- record
detect:
width: 640
height: 480
fps: 5
# Shop Cameras
shop_west:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.56:554/stream1
roles:
- detect
- record
detect:
width: 1024
height: 768
fps: 5
shop_driveway:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.44.57:554/stream1
roles:
- detect
- record
detect:
width: 1024
height: 768
fps: 5
# Other Cameras
pool_room:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.86.142:554/stream1
roles:
- detect
- record
detect:
width: 1280
height: 720
fps: 5
gate_camera:
enabled: true
ffmpeg:
inputs:
- path: rtsp://admin:123456@192.168.86.175:554/stream1
roles:
- detect
- record
detect:
width: 1280
height: 960
fps: 5
# Offline cameras - commented out for now
# front_door:
# enabled: false
# ffmpeg:
# inputs:
# - path: rtsp://admin:123456@192.168.44.59:554/stream1
# detect:
# width: 1024
# height: 768
# fps: 5
# driveway_up:
# enabled: false
# ffmpeg:
# inputs:
# - path: rtsp://admin:123456@192.168.86.150:554/stream1
# driveway_down:
# enabled: false
# ffmpeg:
# inputs:
# - path: rtsp://admin:123456@192.168.44.68:554/stream1
# shop_east:
# enabled: false
# ffmpeg:
# inputs:
# - path: rtsp://admin:123456@192.168.44.55:554/stream1
# UI settings
ui:
timezone: America/Chicago
detect:
enabled: true
version: 0.16-0
+41
View File
@@ -0,0 +1,41 @@
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
restart: unless-stopped
shm_size: "256mb"
environment:
- TZ=America/Chicago
- FRIGATE_RTSP_PASSWORD=password
volumes:
- /data/configs/frigate/config:/config
- /data/configs/frigate/media:/media/frigate
- /etc/localtime:/etc/localtime:ro
devices:
- /dev/dri:/dev/dri
networks:
- traefik-public
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000/api/version || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == bart
labels: []
resources:
limits:
memory: 2G
networks:
traefik-public:
external: true
+1
View File
@@ -0,0 +1 @@
GITEA_DB_PASSWORD=change_me
+84
View File
@@ -0,0 +1,84 @@
version: "3.8"
services:
gitea:
image: gitea/gitea:latest
restart: unless-stopped
ports:
- "3003:3000"
- "2222:22"
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${GITEA_DB_PASSWORD}
- GITEA__server__SSH_PORT=2222
volumes:
- /data/configs/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
- gitea-db
networks:
- gitea-internal
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
gitea-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=gitea
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
volumes:
- /data/configs/gitea-db:/var/lib/postgresql/data
networks:
- gitea-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 256M
networks:
gitea-internal:
driver: overlay
+2
View File
@@ -0,0 +1,2 @@
GUACAMOLE_MYSQL_ROOT_PASSWORD=change_me
GUACAMOLE_MYSQL_PASSWORD=change_me
+104
View File
@@ -0,0 +1,104 @@
services:
guacamole:
image: guacamole/guacamole:latest
restart: unless-stopped
environment:
- GUACD_HOSTNAME=guacd
- MYSQL_HOSTNAME=guacamole-db
- MYSQL_DATABASE=guacamole_db
- MYSQL_USER=guacamole_user
- MYSQL_PASSWORD=${GUACAMOLE_MYSQL_PASSWORD}
ports:
- "8199:8080"
networks:
- traefik-public
- guacamole-internal
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/guacamole/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.guacamole.rule=Host(`solo.hobbs.farm`)"
- "traefik.http.routers.guacamole.entrypoints=websecure"
- "traefik.http.routers.guacamole.tls.certresolver=letsencrypt"
- "traefik.http.services.guacamole.loadbalancer.server.port=8080"
resources:
limits:
memory: 512M
guacd:
image: guacamole/guacd:latest
restart: unless-stopped
networks:
- guacamole-internal
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
guacamole-db:
image: mysql:8.0
user: "1024:100"
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${GUACAMOLE_MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=guacamole_db
- MYSQL_USER=guacamole_user
- MYSQL_PASSWORD=${GUACAMOLE_MYSQL_PASSWORD}
volumes:
- /mnt/docker-data/databases/guacamole-db:/var/lib/mysql
networks:
- guacamole-internal
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 512M
networks:
traefik-public:
external: true
guacamole-internal:
driver: overlay
+1
View File
@@ -0,0 +1 @@
MATRIX_DB_PASSWORD=change_me
+127
View File
@@ -0,0 +1,127 @@
services:
synapse:
image: matrixdotorg/synapse:latest
restart: unless-stopped
environment:
- SYNAPSE_SERVER_NAME=mess.hobbs.farm
- SYNAPSE_REPORT_STATS=no
volumes:
- /mnt/docker-data/configs/matrix/data:/data
ports:
- "8008:8008"
networks:
- traefik-public
- matrix-internal
healthcheck:
test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.services.mess.loadbalancer.server.scheme=http"
- "traefik.http.services.mess.loadbalancer.server.port=8008"
- "traefik.http.routers.mess.rule=Host(`mess.hobbs.farm`)"
- "traefik.http.routers.mess.entrypoints=websecure"
- "traefik.http.routers.mess.tls=true"
- "traefik.http.routers.mess.tls.certresolver=letsencrypt"
- "traefik.http.routers.mess.service=mess"
- "traefik.http.routers.mess-federation.rule=Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)"
- "traefik.http.routers.mess-federation.entrypoints=websecure"
- "traefik.http.routers.mess-federation.tls=true"
- "traefik.http.routers.mess-federation.tls.certresolver=letsencrypt"
- "traefik.http.routers.mess-federation.service=mess"
resources:
limits:
memory: 1G
wellknown:
image: nginx:alpine
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/matrix/wellknown:/usr/share/nginx/html/.well-known/matrix:ro
networks:
- traefik-public
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "traefik.enable=true"
- "traefik.http.services.wellknown.loadbalancer.server.port=80"
- "traefik.http.routers.wellknown.rule=Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)"
- "traefik.http.routers.wellknown.entrypoints=websecure"
- "traefik.http.routers.wellknown.tls=true"
- "traefik.http.routers.wellknown.tls.certresolver=letsencrypt"
- "traefik.http.routers.wellknown.service=wellknown"
resources:
limits:
memory: 64M
synapse-db:
image: postgres:15-alpine
user: "1024:100"
restart: unless-stopped
environment:
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=${MATRIX_DB_PASSWORD}
- POSTGRES_DB=synapse
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data
networks:
- matrix-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 256M
networks:
traefik-public:
external: true
matrix-internal:
driver: overlay
+31
View File
@@ -0,0 +1,31 @@
version: "3.8"
services:
mcpo:
image: ghcr.io/open-webui/mcpo:main
restart: unless-stopped
ports:
- "8001:8000"
volumes:
- /data/configs/mcpo:/app/config
command: ["--config", "/app/config/config.json"]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/openapi.json || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
+195
View File
@@ -0,0 +1,195 @@
version: "3.8"
services:
jellyfin:
image: jellyfin/jellyfin:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
volumes:
- /data/configs/jellyfin:/config
- /data/media:/media
ports:
- "8096:8096"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8096/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
sonarr:
image: linuxserver/sonarr:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /data/configs/sonarr:/config
- /data/media:/media
ports:
- "8989:8989"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8989/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
radarr:
image: linuxserver/radarr:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /data/configs/radarr:/config
- /data/media:/media
ports:
- "7878:7878"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:7878/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
sabnzbd:
image: linuxserver/sabnzbd:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /data/configs/sabnzbd:/config
- /data/media/downloads:/downloads
ports:
- "8082:8080"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api?mode=version || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 1G
nzbhydra2:
image: linuxserver/nzbhydra2:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
volumes:
- /data/configs/nzbhydra:/config
ports:
- "5076:5076"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
jellyseerr:
image: fallenbagel/jellyseerr:latest
restart: unless-stopped
environment:
- TZ=America/Chicago
volumes:
- /data/configs/jellyseerr:/app/config
ports:
- "5055:5055"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5055/api/v1/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
+40
View File
@@ -0,0 +1,40 @@
global:
resolve_timeout: 5m
route:
group_by: ["alertname"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: "matrix-alerts"
routes:
- match:
severity: critical
repeat_interval: 30m
receiver: "matrix-alerts"
- match:
alertname: Watchdog
receiver: "null"
repeat_interval: 24h
- match:
severity: info
repeat_interval: 12h
receiver: "matrix-alerts"
receivers:
- name: "null"
- name: "matrix-alerts"
webhook_configs:
- url: "http://matrix-alertmanager-receiver:9094/alerts/notifications"
http_config:
basic_auth:
username: "alertmanager"
password: "alertmanager-webhook"
send_resolved: true
inhibit_rules:
- source_match:
severity: "critical"
target_match:
severity: "warning"
equal: ["alertname", "instance"]
+244
View File
@@ -0,0 +1,244 @@
groups:
- name: infrastructure_devices
interval: 30s
rules:
# Unifi Device Down
- alert: UnifiDeviceDown
expr: unifi_device_up == 0
for: 2m
labels:
severity: critical
type: infrastructure
annotations:
summary: "Unifi device {{ $labels.name }} is offline"
description: "Device {{ $labels.name }} ({{ $labels.model }}) at {{ $labels.ip }} has been offline for more than 2 minutes."
- name: server_health
interval: 60s
rules:
# Server Down
- alert: ServerDown
expr: up{job="node"} == 0
for: 1m
labels:
severity: critical
type: server
annotations:
summary: "Server {{ $labels.hostname }} is DOWN"
description: "{{ $labels.hostname }} ({{ $labels.host_ip }}) is not responding to health checks for 1 minute."
# High disk usage
- alert: HighDiskUsage
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
for: 5m
labels:
severity: critical
type: server
annotations:
summary: "{{ $labels.hostname }}: Disk space critically low"
description: "Root filesystem on {{ $labels.hostname }} has less than 10% free space remaining."
# Moderate disk usage warning
- alert: DiskSpaceWarning
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 20
for: 10m
labels:
severity: warning
type: server
annotations:
summary: "{{ $labels.hostname }}: Disk space low"
description: "Root filesystem on {{ $labels.hostname }} has less than 20% free space."
# High memory usage
- alert: HighMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 90
for: 5m
labels:
severity: warning
type: server
annotations:
summary: "{{ $labels.hostname }}: High memory usage"
description: "Memory usage on {{ $labels.hostname }} is above 90% for 5 minutes."
# Critical memory usage
- alert: CriticalMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 95
for: 2m
labels:
severity: critical
type: server
annotations:
summary: "{{ $labels.hostname }}: Critical memory usage"
description: "Memory usage on {{ $labels.hostname }} is above 95%. System may become unstable."
# High CPU usage (bart: 90% threshold due to Frigate/media workloads, others: 80%)
- alert: HighCPUUsage
expr: >
(100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle",hostname!="bart"}[5m])) * 100) > 80)
or
(100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle",hostname="bart"}[5m])) * 100) > 90)
for: 10m
labels:
severity: warning
type: server
annotations:
summary: "{{ $labels.hostname }}: High CPU usage"
description: "CPU usage on {{ $labels.hostname }} is above threshold for 10 minutes."
# Critical CPU usage
- alert: CriticalCPUUsage
expr: 100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 95
for: 5m
labels:
severity: critical
type: server
annotations:
summary: "{{ $labels.hostname }}: Critical CPU usage"
description: "CPU usage on {{ $labels.hostname }} is above 95% for 5 minutes. Performance degraded."
# High system load
- alert: HighSystemLoad
expr: node_load5 / on(hostname) count by(hostname) (node_cpu_seconds_total{mode="idle"}) > 2
for: 10m
labels:
severity: warning
type: server
annotations:
summary: "{{ $labels.hostname }}: High system load"
description: "5-minute load average on {{ $labels.hostname }} is more than 2x CPU count."
# Server rebooted
- alert: ServerRebooted
expr: node_time_seconds - node_boot_time_seconds < 600
for: 1m
labels:
severity: info
type: server
annotations:
summary: "{{ $labels.hostname }} rebooted"
description: "{{ $labels.hostname }} was rebooted less than 10 minutes ago."
# High swap usage
- alert: HighSwapUsage
expr: (node_memory_SwapTotal_bytes > 0) and ((node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes * 100 > 50)
for: 5m
labels:
severity: warning
type: server
annotations:
summary: "{{ $labels.hostname }}: High swap usage"
description: "Swap usage on {{ $labels.hostname }} is above 50% for 5 minutes. Check container memory limits."
# Critical swap usage
- alert: CriticalSwapUsage
expr: (node_memory_SwapTotal_bytes > 0) and ((node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes * 100 > 80)
for: 2m
labels:
severity: critical
type: server
annotations:
summary: "{{ $labels.hostname }}: Critical swap usage"
description: "Swap usage on {{ $labels.hostname }} is above 80%. System may become unresponsive."
# NFS mount failure (all data is on NAS - this is critical)
- alert: NFSMountDown
expr: node_filesystem_avail_bytes{fstype="nfs"} == 0 or absent(node_filesystem_avail_bytes{fstype="nfs"})
for: 1m
labels:
severity: critical
type: server
annotations:
summary: "{{ $labels.hostname }}: NFS mount unavailable"
description: "NFS mount on {{ $labels.hostname }} at {{ $labels.mountpoint }} is down. All services depend on NAS storage."
# NAS disk space low
- alert: NASDiskSpaceLow
expr: (node_filesystem_avail_bytes{fstype="nfs",mountpoint="/mnt/docker-data"} / node_filesystem_size_bytes{fstype="nfs",mountpoint="/mnt/docker-data"}) * 100 < 15
for: 10m
labels:
severity: warning
type: server
annotations:
summary: "NAS disk space low"
description: "NFS share /mnt/docker-data has less than 15% free space."
- name: docker_containers
interval: 60s
rules:
# Service/container unreachable via blackbox HTTP probe
# Matches all http-pdocker01, http-pdocker02, http-bart, and http-external jobs
- alert: ContainerDown
expr: probe_success{job=~"http-.*"} == 0
for: 3m
labels:
severity: warning
type: container
annotations:
summary: "Service unreachable: {{ $labels.instance }}"
description: "Service at {{ $labels.instance }} is not responding to HTTP health checks for 3 minutes."
- name: service_health
interval: 60s
rules:
# Service responding but very slow
- alert: SlowHTTPResponse
expr: probe_duration_seconds{job=~"http-.*"} > 10
for: 5m
labels:
severity: warning
type: container
annotations:
summary: "Slow response: {{ $labels.instance }}"
description: "{{ $labels.instance }} is responding but taking more than 10 seconds."
# Prometheus scrape target down
- alert: PrometheusTargetDown
expr: up == 0
for: 5m
labels:
severity: warning
type: monitoring
annotations:
summary: "Prometheus target down: {{ $labels.job }}"
description: "Prometheus cannot scrape {{ $labels.instance }} (job: {{ $labels.job }}) for 5 minutes."
# Alerting pipeline dead-man's switch (Watchdog always fires - if it stops, pipeline is broken)
- alert: Watchdog
expr: vector(1)
labels:
severity: none
type: monitoring
annotations:
summary: "Alerting pipeline active"
description: "This is a dead-man's-switch alert. If you stop receiving this, the alerting pipeline is broken."
- name: frigate_alerts
interval: 60s
rules:
# Frigate high memory (standalone container on hf-pdocker-01 with host network)
- alert: FrigateHighMemory
expr: container_memory_usage_bytes{name="frigate"} / container_spec_memory_limit_bytes{name="frigate"} > 0.85
for: 5m
labels:
severity: warning
type: frigate
annotations:
summary: "Frigate high memory usage"
description: "Frigate is using more than 85% of allocated memory."
- name: service_resources
interval: 60s
rules:
# Any service container using more than 90% of its memory limit
- alert: ServiceHighMemory
expr: >
(container_memory_usage_bytes{name!="",name!~".*_db.*",name!~".*db$"} /
container_spec_memory_limit_bytes{name!="",name!~".*_db.*",name!~".*db$"} > 0)
> 0.90
for: 5m
labels:
severity: warning
type: container
annotations:
summary: "{{ $labels.name }}: High memory usage"
description: "Container {{ $labels.name }} on {{ $labels.instance }} is using more than 90% of its memory limit."
+17
View File
@@ -0,0 +1,17 @@
modules:
http_2xx:
prober: http
timeout: 10s
http:
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
follow_redirects: true
preferred_ip_protocol: "ip4"
http_2xx_insecure:
prober: http
timeout: 10s
http:
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
follow_redirects: true
preferred_ip_protocol: "ip4"
tls_config:
insecure_skip_verify: true
+197
View File
@@ -0,0 +1,197 @@
services:
grafana:
image: grafana/grafana:latest
restart: unless-stopped
user: "104"
ports:
- "3001:3000"
volumes:
- /mnt/docker-data/configs/grafana:/var/lib/grafana
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
prometheus:
image: prom/prometheus:latest
restart: unless-stopped
ports:
- "9095:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
volumes:
- /mnt/docker-data/configs/prometheus:/etc/prometheus
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
alertmanager:
image: prom/alertmanager:latest
restart: unless-stopped
ports:
- "9093:9093"
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
- /mnt/docker-data/configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
node-exporter:
image: prom/node-exporter:latest
restart: unless-stopped
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: global
resources:
limits:
memory: 128M
influxdb:
image: influxdb:1.8-alpine
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/influxdb:/var/lib/influxdb
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 1G
matrix-alertmanager-receiver:
image: metio/matrix-alertmanager-receiver:2026.2.25
restart: unless-stopped
ports:
- "9094:9094"
volumes:
- /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro
command: ["--config-path", "/config/config.yml"]
# NOTE: No healthcheck - this image is distroless (no shell), so CMD-SHELL healthchecks fail.
# The service is verified healthy by Alertmanager successfully POSTing to it.
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 128M
blackbox-exporter:
image: prom/blackbox-exporter:latest
restart: unless-stopped
ports:
- "9115:9115"
command:
- '--config.file=/config/blackbox.yml'
volumes:
- /mnt/docker-data/configs/blackbox:/config:ro
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 128M
volumes:
alertmanager-data:
@@ -0,0 +1,55 @@
http:
address: 0.0.0.0
port: 9094
alerts-path-prefix: /alerts
metrics-path: /metrics
metrics-enabled: true
# basic-password is required - the app has a hardcoded default basic-username of "alertmanager"
# and will exit silently if basic-password is not set
basic-password: "alertmanager-webhook"
matrix:
# Matrix Synapse is on bart (failover from hf-pdocker-01)
homeserver-url: http://192.168.86.167:8008
user-id: "@alertbot:hobbs.farm"
# Access token stored directly - ${MATRIX_ACCESS_TOKEN} env var syntax is NOT supported
# (the app uses yaml parsing, not shell interpolation)
access-token: "syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ"
room-mapping:
notifications: "!jYmqxbzEsvRgKYvwMP:hobbs.farm"
templating:
external-url-mapping:
# Alertmanager and Prometheus are on hf-pdocker-02 (failover from hf-pdocker-01)
"http://alertmanager:9093": "http://192.168.86.100:9093"
generator-url-mapping:
"http://prometheus:9090": "http://192.168.86.100:9095"
computed-values:
- values:
color: red
when-matching-labels:
severity: critical
- values:
color: orange
when-matching-labels:
severity: warning
- values:
color: blue
when-matching-labels:
severity: info
# NOTE: Templates use .Alert (singular) not .Alerts (plural)
# This application calls the template once per individual alert, not per group
firing-template: |
<h3>ALERT: {{ .Alert.Labels.alertname }}</h3>
<table>
<tr><td><b>Instance:</b></td><td>{{ .Alert.Labels.instance }}</td></tr>
<tr><td><b>Severity:</b></td><td>{{ .Alert.Labels.severity }}</td></tr>
<tr><td><b>Summary:</b></td><td>{{ .Alert.Annotations.summary }}</td></tr>
<tr><td><b>Description:</b></td><td>{{ .Alert.Annotations.description }}</td></tr>
</table>
resolved-template: |
<h3>RESOLVED: {{ .Alert.Labels.alertname }}</h3>
<table>
<tr><td><b>Instance:</b></td><td>{{ .Alert.Labels.instance }}</td></tr>
<tr><td><b>Summary:</b></td><td>{{ .Alert.Annotations.summary }}</td></tr>
</table>
+163
View File
@@ -0,0 +1,163 @@
global:
scrape_interval: 1m
evaluation_interval: 1m
alerting:
alertmanagers:
- static_configs:
- targets:
- 'alertmanager:9093'
rule_files:
- "alerts.yml"
scrape_configs:
# Prometheus self-monitoring
- job_name: 'prometheus'
scrape_interval: 1m
static_configs:
- targets: ['localhost:9090']
# Node Exporter (system metrics) - all 3 swarm nodes
- job_name: 'node'
static_configs:
- targets: ['192.168.86.192:9100']
labels:
hostname: 'hf-pdocker-01'
host_ip: '192.168.86.192'
- targets: ['192.168.86.167:9100']
labels:
hostname: 'bart'
host_ip: '192.168.86.167'
- targets: ['192.168.86.100:9100']
labels:
hostname: 'hf-pdocker-02'
host_ip: '192.168.86.100'
# Blackbox Exporter self-monitoring
- job_name: 'blackbox'
static_configs:
- targets: ['blackbox-exporter:9115']
# Matrix Alertmanager Receiver metrics (running on hf-pdocker-02 during failover)
- job_name: 'matrix-alertmanager-receiver'
scrape_interval: 1m
static_configs:
- targets: ['192.168.86.100:9094']
labels:
hostname: 'hf-pdocker-02'
# HTTP Health Checks - hf-pdocker-01 services (native services only; failover services tracked in other jobs)
- job_name: 'http-pdocker01'
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://192.168.86.192:5000 # Frigate NVR (standalone, host network)
- http://192.168.86.192:5199/health # Frigate Notify
- http://192.168.86.192:8888/health # Farm Manager API
- http://192.168.86.192:8889/health # Farm Agent
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# HTTP Health Checks - hf-pdocker-02 services (native + failover from hf-pdocker-01)
- job_name: 'http-pdocker02'
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://192.168.86.100:3003 # Gitea
- http://192.168.86.100:3004 # Semaphore
- http://192.168.86.100:3002 # Wiki.js
- http://192.168.86.100:26226 # Vaultwarden
- http://192.168.86.100:5006 # Actual Budget
- http://192.168.86.100:8001/openapi.json # MCPO
- http://192.168.86.100:8889/health # Farm Agent
- http://192.168.86.100:3001 # Grafana (FAILOVER from hf-pdocker-01)
- http://192.168.86.100:9095 # Prometheus (FAILOVER from hf-pdocker-01)
- http://192.168.86.100:9093 # Alertmanager (FAILOVER from hf-pdocker-01)
- http://192.168.86.100:3000 # AdGuard Home (FAILOVER from hf-pdocker-01)
- http://192.168.86.100:9000 # Portainer
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# HTTPS Health Checks - services with self-signed certs
- job_name: 'http-pdocker02-https'
metrics_path: /probe
params:
module: [http_2xx_insecure]
static_configs:
- targets:
- https://192.168.86.100:8443 # Unifi Controller (self-signed cert)
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# HTTP Health Checks - bart services (native + failover from hf-pdocker-01)
- job_name: 'http-bart'
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://192.168.86.167:8096 # Jellyfin
- http://192.168.86.167:8989 # Sonarr
- http://192.168.86.167:7878 # Radarr
- http://192.168.86.167:8082 # SABnzbd
- http://192.168.86.167:5076 # NZBHydra2
- http://192.168.86.167:5055 # Jellyseerr
- http://192.168.86.167:9392 # OpenVAS
- http://192.168.86.167:8889/health # Farm Agent
- http://192.168.86.167:8123 # Home Assistant (FAILOVER from hf-pdocker-01)
- http://192.168.86.167:6052 # ESPHome (FAILOVER from hf-pdocker-01)
- http://192.168.86.167:8199/guacamole/ # Guacamole (FAILOVER from hf-pdocker-01)
- http://192.168.86.167:8008 # Matrix Synapse (FAILOVER from hf-pdocker-01)
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# HTTPS Health Checks - External domains via Traefik
- job_name: 'http-external'
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- https://mess.hobbs.farm # Matrix (via Traefik)
- https://solo.hobbs.farm/guacamole/ # Guacamole (via Traefik)
- https://yoda.hobbs.farm # Home Assistant
- https://vault.hobbs.farm # Vaultwarden
- https://git.hobbs.farm # Gitea
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
# Unifi Network Devices - uncomment when unifi-exporter is deployed
# - job_name: 'unifi-devices'
# scrape_interval: 30s
# static_configs:
# - targets: ['unifi-exporter:9101']
+2
View File
@@ -0,0 +1,2 @@
N8N_DB_PASSWORD=change_me
N8N_ENCRYPTION_KEY=change_me_32_char_random_key_here
+82
View File
@@ -0,0 +1,82 @@
version: "3.8"
services:
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
ports:
- "5678:5678"
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=n8n-db
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
- N8N_PORT=5678
- GENERIC_TIMEZONE=America/Chicago
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
volumes:
- /data/configs/n8n:/home/node/.n8n
depends_on:
- n8n-db
networks:
- n8n-internal
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
n8n-db:
image: postgres:15-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
volumes:
- /data/configs/n8n-db:/var/lib/postgresql/data
networks:
- n8n-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 256M
networks:
n8n-internal:
driver: overlay
+24
View File
@@ -0,0 +1,24 @@
services:
node-exporter:
image: prom/node-exporter:latest
hostname: "{{.Node.Hostname}}"
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: global
resources:
limits:
memory: 128M
+24
View File
@@ -0,0 +1,24 @@
services:
openvas:
image: immauss/openvas:latest
environment:
- PASSWORD=admin
- USERNAME=admin
volumes:
- openvas_data:/data
ports:
- "9392:9392"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 6G
reservations:
memory: 4G
volumes:
openvas_data:
+4
View File
@@ -0,0 +1,4 @@
SEMAPHORE_DB_PASSWORD=change_me
SEMAPHORE_ADMIN_PASSWORD=change_me
SEMAPHORE_ENCRYPTION_KEY=change_me_base64_encoded_32_byte_key
SEMAPHORE_MYSQL_ROOT_PASSWORD=change_me
+89
View File
@@ -0,0 +1,89 @@
version: "3.8"
services:
semaphore:
image: semaphoreui/semaphore:latest
restart: unless-stopped
ports:
- "3004:3000"
environment:
- SEMAPHORE_DB_DIALECT=mysql
- SEMAPHORE_DB_HOST=semaphore-db
- SEMAPHORE_DB_PORT=3306
- SEMAPHORE_DB_USER=semaphore
- SEMAPHORE_DB_PASS=${SEMAPHORE_DB_PASSWORD}
- SEMAPHORE_DB_NAME=semaphore
- SEMAPHORE_DB=semaphore
- SEMAPHORE_PLAYBOOK_PATH=/tmp/semaphore
- SEMAPHORE_ADMIN_PASSWORD=${SEMAPHORE_ADMIN_PASSWORD}
- SEMAPHORE_ADMIN_NAME=admin
- SEMAPHORE_ADMIN_EMAIL=admin@hobbs.farm
- SEMAPHORE_ADMIN=admin
- SEMAPHORE_ACCESS_KEY_ENCRYPTION=${SEMAPHORE_ENCRYPTION_KEY}
- SEMAPHORE_LDAP_ACTIVATED=no
volumes:
- /data/configs/semaphore:/etc/semaphore
- /data/configs/semaphore/playbooks:/tmp/semaphore
depends_on:
- semaphore-db
networks:
- semaphore-internal
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
semaphore-db:
image: mysql:8.0
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${SEMAPHORE_MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=semaphore
- MYSQL_USER=semaphore
- MYSQL_PASSWORD=${SEMAPHORE_DB_PASSWORD}
volumes:
- /data/configs/semaphore-db:/var/lib/mysql
networks:
- semaphore-internal
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 512M
networks:
semaphore-internal:
driver: overlay
+97
View File
@@ -0,0 +1,97 @@
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
restart: unless-stopped
privileged: true
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/homeassistant:/config
ports:
- "8123:8123"
dns:
- 192.168.86.1
- 8.8.8.8
networks:
- traefik-public
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.services.yoda.loadbalancer.server.scheme=http"
- "traefik.http.services.yoda.loadbalancer.server.port=8123"
- "traefik.http.routers.yoda.rule=Host(`yoda.hobbs.farm`)"
- "traefik.http.routers.yoda.entrypoints=websecure"
- "traefik.http.routers.yoda.tls=true"
- "traefik.http.routers.yoda.tls.certresolver=letsencrypt"
- "traefik.http.routers.yoda.service=yoda"
resources:
limits:
memory: 4G
esphome:
image: esphome/esphome:latest
restart: unless-stopped
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/esphome/config:/config
ports:
- "6052:6052"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:6052/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 2G
mosquitto:
image: eclipse-mosquitto:latest
restart: unless-stopped
volumes:
- /mnt/docker-data/configs/mosquitto:/mosquitto
ports:
- "1883:1883"
- "8883:8883"
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 128M
networks:
traefik-public:
external: true
+25
View File
@@ -0,0 +1,25 @@
services:
telegraf:
image: telegraf:latest
restart: unless-stopped
hostname: "{{.Node.Hostname}}"
environment:
- HOST_PROC=/host/proc
- HOST_SYS=/host/sys
- HOST_ETC=/host/etc
volumes:
- /mnt/docker-data/configs/telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /sys:/host/sys:ro
- /proc:/host/proc:ro
- /etc:/host/etc:ro
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: global
resources:
limits:
memory: 512M
+25
View File
@@ -0,0 +1,25 @@
[agent]
interval = "10s"
round_interval = true
metric_batch_size = 1000
metric_buffer_limit = 10000
[[outputs.influxdb]]
# InfluxDB is on hf-pdocker-02 (Compose service, not on Swarm overlay)
urls = ["http://192.168.86.100:8086"]
database = "telegraf"
# Docker input disabled due to permission issues across nodes with different GIDs
# [[inputs.docker]]
# endpoint = "unix:///var/run/docker.sock"
[[inputs.cpu]]
percpu = true
totalcpu = true
[[inputs.disk]]
ignore_fs = ["tmpfs", "devtmpfs", "overlay"]
[[inputs.mem]]
[[inputs.system]]
[[inputs.net]]
+1
View File
@@ -0,0 +1 @@
CF_DNS_API_TOKEN=change_me_cloudflare_api_token
+58
View File
@@ -0,0 +1,58 @@
services:
traefik:
image: traefik:v3.6
command:
- "--api.insecure=true"
- "--providers.swarm=true"
- "--providers.swarm.exposedbydefault=false"
- "--providers.file.directory=/config/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.letsencrypt.acme.email=jeremydanielhobbs@gmail.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
ports:
- target: 80
published: 80
mode: host
- target: 443
published: 443
mode: host
- target: 8080
published: 8090
mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /mnt/docker-data/traefik/letsencrypt:/letsencrypt
- /mnt/docker-data/traefik/dynamic:/config/dynamic:ro
networks:
- traefik-public
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/api/overview || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
networks:
traefik-public:
driver: overlay
attachable: true
external: true
+26
View File
@@ -0,0 +1,26 @@
http:
routers:
gitea:
rule: "Host(`git.hobbs.farm`)"
entryPoints:
- websecure
service: gitea
tls:
certResolver: letsencrypt
gitea-http:
rule: "Host(`git.hobbs.farm`)"
entryPoints:
- web
middlewares:
- gitea-https-redirect
service: gitea
middlewares:
gitea-https-redirect:
redirectScheme:
scheme: https
permanent: true
services:
gitea:
loadBalancer:
servers:
- url: "http://192.168.86.100:3003"
+26
View File
@@ -0,0 +1,26 @@
http:
routers:
vault:
rule: "Host(`vault.hobbs.farm`)"
entryPoints:
- websecure
service: vault
tls:
certResolver: letsencrypt
vault-http:
rule: "Host(`vault.hobbs.farm`)"
entryPoints:
- web
middlewares:
- vault-https-redirect
service: vault
middlewares:
vault-https-redirect:
redirectScheme:
scheme: https
permanent: true
services:
vault:
loadBalancer:
servers:
- url: "http://192.168.86.100:26226"
+17
View File
@@ -0,0 +1,17 @@
services:
trivy:
image: aquasec/trivy:latest
command: server --listen 0.0.0.0:4954
ports:
- "4954:4954"
volumes:
- trivy-cache:/root/.cache/
- /var/run/docker.sock:/var/run/docker.sock:ro
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
volumes:
trivy-cache:
+2
View File
@@ -0,0 +1,2 @@
UNIFI_MONGO_USER=unifi
UNIFI_MONGO_PASS=change_me
+77
View File
@@ -0,0 +1,77 @@
version: "3.8"
services:
unifi:
image: lscr.io/linuxserver/unifi-network-application:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/Chicago
- MONGO_USER=${UNIFI_MONGO_USER}
- MONGO_PASS=${UNIFI_MONGO_PASS}
- MONGO_HOST=unifi-db
- MONGO_PORT=27017
- MONGO_DBNAME=unifi
volumes:
- /data/configs/unifi/data:/config
ports:
- "8443:8443"
- "3478:3478/udp"
- "10001:10001/udp"
- "8080:8080"
- "1900:1900/udp"
- "8843:8843"
- "8880:8880"
- "6789:6789"
- "5514:5514/udp"
networks:
- unifi-internal
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 1G
unifi-db:
image: mongo:7.0
restart: unless-stopped
volumes:
- /data/configs/unifi/mongo-data:/data/db
networks:
- unifi-internal
healthcheck:
test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' --quiet"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
labels:
- "com.centurylinklabs.watchtower.enable=false"
resources:
limits:
memory: 1G
networks:
unifi-internal:
driver: overlay
+34
View File
@@ -0,0 +1,34 @@
version: "3.8"
services:
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
environment:
- DOMAIN=https://vault.hobbs.farm
- SIGNUPS_ALLOWED=false
volumes:
- /data/configs/vaultwarden:/data
ports:
- "26226:80"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/alive || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
+27
View File
@@ -0,0 +1,27 @@
services:
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_MONITOR_ONLY=false
- TZ=America/Chicago
- DOCKER_API_VERSION=1.44
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M

Some files were not shown because too many files have changed in this diff Show More