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
+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