diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18d25d0 --- /dev/null +++ b/.env.example @@ -0,0 +1,90 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Network Operations Monitoring and Automation Dashboard +# ============================================================================= +# Copy this file to .env and configure for your environment. +# All values marked "replaceme" MUST be changed before starting. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Application Settings +# ----------------------------------------------------------------------------- +NODE_ENV=production +PORT=8080 +HOST=0.0.0.0 +LOG_LEVEL=info + +# Generate a random key: openssl rand -hex 32 +APP_KEY=replaceme + +# The external URL where Nomad will be accessed (used for links, redirects) +# Examples: http://192.168.1.100:8080, https://nomad.home.local +URL=http://localhost:8080 + +# Optional: Base URL path if running behind a reverse proxy with a subpath +# BASE_URL=/ + +# ----------------------------------------------------------------------------- +# Database (MySQL 8.0) +# ----------------------------------------------------------------------------- +DB_HOST=nomad-database +DB_PORT=3306 +DB_DATABASE=nomad +DB_USER=nomad +DB_PASSWORD=replaceme +DB_SSL=false + +# MySQL root password (used for initial database creation) +MYSQL_ROOT_PASSWORD=replaceme + +# ----------------------------------------------------------------------------- +# Redis Cache / Queue +# ----------------------------------------------------------------------------- +REDIS_HOST=nomad-cache +REDIS_PORT=6379 + +# Optional: Redis password (uncomment to enable) +# REDIS_PASSWORD= + +# ----------------------------------------------------------------------------- +# Storage Paths (inside container — mapped via Docker volumes) +# ----------------------------------------------------------------------------- +# Path where Nomad stores content (ZIM files, maps, uploads) +NOMAD_STORAGE_PATH=/app/storage + +# ----------------------------------------------------------------------------- +# NAS / Host Storage Mapping +# ----------------------------------------------------------------------------- +# Set these to your NAS storage paths on the HOST system. +# These are used in docker-compose.yml volume mappings. +# +# Unraid example: /mnt/user/appdata/project-nomad +# TrueNAS example: /mnt/pool/apps/project-nomad +# Linux example: /opt/project-nomad +NOMAD_DATA_DIR=/opt/project-nomad + +# ----------------------------------------------------------------------------- +# Reverse Proxy Settings +# ----------------------------------------------------------------------------- +# Set to 'true' if running behind a TLS-terminating reverse proxy +# TRUSTED_PROXY=false + +# Trusted proxy IP ranges (comma-separated CIDR) +# TRUSTED_PROXY_IPS=172.16.0.0/12,192.168.0.0/16,10.0.0.0/8 + +# ----------------------------------------------------------------------------- +# Monitoring Agent Settings (optional) +# ----------------------------------------------------------------------------- +# AGENT_SECRET=replaceme +# AGENT_PORT=9100 +# AGENT_COLLECT_INTERVAL=30 + +# ----------------------------------------------------------------------------- +# Optional: External API URL for Nomad benchmark leaderboard +# ----------------------------------------------------------------------------- +# NOMAD_API_URL=https://api.projectnomad.io + +# ----------------------------------------------------------------------------- +# Optional: Internet connectivity test URL +# ----------------------------------------------------------------------------- +# INTERNET_STATUS_TEST_URL=https://connectivity-check.ubuntu.com diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..26138eb --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,44 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Lightweight Monitoring Agent +# ============================================================================= +# A minimal monitoring agent that collects system metrics and reports +# them to the Nomad server. Designed for low resource usage. +# +# Build: docker build -t nomad-agent ./agent +# Run: docker run -d --name nomad-agent \ +# -e NOMAD_SERVER_URL=http://your-nomad-server:8080 \ +# -e AGENT_SECRET=your-secret \ +# -v /var/run/docker.sock:/var/run/docker.sock:ro \ +# -v /proc:/host/proc:ro \ +# -v /sys:/host/sys:ro \ +# nomad-agent +# ============================================================================= + +FROM node:22-alpine + +RUN apk add --no-cache curl + +WORKDIR /agent + +COPY package.json ./ +RUN npm ci --omit=dev 2>/dev/null || npm init -y + +COPY agent.mjs ./ +COPY healthcheck.mjs ./ + +# Run as non-root +RUN addgroup -g 1001 -S nomad && \ + adduser -S nomad -u 1001 -G nomad +USER nomad + +ENV NODE_ENV=production +ENV AGENT_PORT=9100 +ENV COLLECT_INTERVAL=30 + +EXPOSE 9100 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD node healthcheck.mjs || exit 1 + +CMD ["node", "agent.mjs"] diff --git a/agent/agent.mjs b/agent/agent.mjs new file mode 100644 index 0000000..a64f820 --- /dev/null +++ b/agent/agent.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node +// ============================================================================= +// PROJECT N.O.M.A.D. — Homelab Edition +// Lightweight Monitoring Agent +// ============================================================================= +// Collects system metrics and reports them to the Nomad server. +// Designed for minimal CPU and RAM usage on homelab nodes. +// ============================================================================= + +import { createServer } from 'node:http' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { hostname, cpus, totalmem, freemem, uptime, networkInterfaces, platform, arch, release } from 'node:os' + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- +const config = { + port: parseInt(process.env.AGENT_PORT || '9100', 10), + collectInterval: parseInt(process.env.COLLECT_INTERVAL || '30', 10) * 1000, + serverUrl: process.env.NOMAD_SERVER_URL || '', + agentSecret: process.env.AGENT_SECRET || '', + nodeName: process.env.NODE_NAME || hostname(), + hostProcPath: process.env.HOST_PROC || '/host/proc', + hostSysPath: process.env.HOST_SYS || '/host/sys', +} + +// --------------------------------------------------------------------------- +// Metrics Collection +// --------------------------------------------------------------------------- + +/** Collect CPU usage from /proc/stat or os module */ +function collectCpuMetrics() { + const cpuList = cpus() + const cpuCount = cpuList.length + let totalIdle = 0 + let totalTick = 0 + + for (const cpu of cpuList) { + const { user, nice, sys, idle, irq } = cpu.times + totalTick += user + nice + sys + idle + irq + totalIdle += idle + } + + return { + count: cpuCount, + model: cpuList[0]?.model || 'unknown', + usagePercent: cpuCount > 0 ? Math.round((1 - totalIdle / totalTick) * 100 * 100) / 100 : 0, + } +} + +/** Collect memory metrics */ +function collectMemoryMetrics() { + const total = totalmem() + const free = freemem() + const used = total - free + + return { + totalBytes: total, + usedBytes: used, + freeBytes: free, + usagePercent: Math.round((used / total) * 100 * 100) / 100, + } +} + +/** Collect disk usage from /proc/mounts and /proc/diskstats */ +function collectDiskMetrics() { + const disks = [] + + try { + const mountsPath = `${config.hostProcPath}/mounts` + if (existsSync(mountsPath)) { + const mounts = readFileSync(mountsPath, 'utf-8') + const lines = mounts.split('\n').filter((l) => l.startsWith('/dev/')) + + for (const line of lines) { + const parts = line.split(/\s+/) + if (parts.length >= 4) { + disks.push({ + device: parts[0], + mountPoint: parts[1], + fsType: parts[2], + }) + } + } + } + } catch { + // Disk metrics may not be available in all environments + } + + return disks +} + +/** Collect network interface information */ +function collectNetworkMetrics() { + const interfaces = networkInterfaces() + const nets = [] + + for (const [name, addrs] of Object.entries(interfaces)) { + if (!addrs) continue + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + nets.push({ + interface: name, + address: addr.address, + mac: addr.mac, + }) + } + } + } + + return nets +} + +/** Collect Docker container status via Docker socket */ +async function collectDockerMetrics() { + const containers = [] + + try { + const socketPath = '/var/run/docker.sock' + if (!existsSync(socketPath)) return containers + + const response = await fetch('http://localhost/containers/json?all=true', { + socketPath, + }).catch(() => null) + + if (response && response.ok) { + const data = await response.json() + for (const container of data) { + containers.push({ + id: container.Id?.substring(0, 12), + name: container.Names?.[0]?.replace(/^\//, ''), + image: container.Image, + state: container.State, + status: container.Status, + }) + } + } + } catch { + // Docker metrics may not be available + } + + return containers +} + +/** Collect all metrics */ +async function collectAllMetrics() { + const [dockerContainers] = await Promise.all([collectDockerMetrics()]) + + return { + timestamp: new Date().toISOString(), + node: { + name: config.nodeName, + platform: platform(), + arch: arch(), + release: release(), + uptime: Math.floor(uptime()), + }, + cpu: collectCpuMetrics(), + memory: collectMemoryMetrics(), + disks: collectDiskMetrics(), + network: collectNetworkMetrics(), + docker: { + containers: dockerContainers, + containerCount: dockerContainers.length, + }, + } +} + +// --------------------------------------------------------------------------- +// Metrics Reporting +// --------------------------------------------------------------------------- + +/** Send metrics to Nomad server */ +async function reportMetrics(metrics) { + if (!config.serverUrl) return + + try { + const url = `${config.serverUrl}/api/agent/report` + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.agentSecret}`, + }, + body: JSON.stringify(metrics), + signal: AbortSignal.timeout(10000), + }) + } catch { + // Silently retry on next interval + } +} + +// --------------------------------------------------------------------------- +// Prometheus Metrics Endpoint +// --------------------------------------------------------------------------- + +/** Format metrics as Prometheus exposition format */ +function formatPrometheusMetrics(metrics) { + const lines = [] + + // CPU metrics + lines.push('# HELP nomad_agent_cpu_usage_percent CPU usage percentage') + lines.push('# TYPE nomad_agent_cpu_usage_percent gauge') + lines.push(`nomad_agent_cpu_usage_percent{node="${metrics.node.name}"} ${metrics.cpu.usagePercent}`) + + lines.push('# HELP nomad_agent_cpu_count Number of CPU cores') + lines.push('# TYPE nomad_agent_cpu_count gauge') + lines.push(`nomad_agent_cpu_count{node="${metrics.node.name}"} ${metrics.cpu.count}`) + + // Memory metrics + lines.push('# HELP nomad_agent_memory_total_bytes Total memory in bytes') + lines.push('# TYPE nomad_agent_memory_total_bytes gauge') + lines.push(`nomad_agent_memory_total_bytes{node="${metrics.node.name}"} ${metrics.memory.totalBytes}`) + + lines.push('# HELP nomad_agent_memory_used_bytes Used memory in bytes') + lines.push('# TYPE nomad_agent_memory_used_bytes gauge') + lines.push(`nomad_agent_memory_used_bytes{node="${metrics.node.name}"} ${metrics.memory.usedBytes}`) + + lines.push('# HELP nomad_agent_memory_usage_percent Memory usage percentage') + lines.push('# TYPE nomad_agent_memory_usage_percent gauge') + lines.push(`nomad_agent_memory_usage_percent{node="${metrics.node.name}"} ${metrics.memory.usagePercent}`) + + // Uptime + lines.push('# HELP nomad_agent_uptime_seconds System uptime in seconds') + lines.push('# TYPE nomad_agent_uptime_seconds gauge') + lines.push(`nomad_agent_uptime_seconds{node="${metrics.node.name}"} ${metrics.node.uptime}`) + + // Docker containers + lines.push('# HELP nomad_agent_docker_containers Number of Docker containers') + lines.push('# TYPE nomad_agent_docker_containers gauge') + lines.push(`nomad_agent_docker_containers{node="${metrics.node.name}"} ${metrics.docker.containerCount}`) + + for (const container of metrics.docker.containers) { + const running = container.state === 'running' ? 1 : 0 + lines.push(`nomad_agent_docker_container_running{node="${metrics.node.name}",name="${container.name}",image="${container.image}"} ${running}`) + } + + return lines.join('\n') + '\n' +} + +// --------------------------------------------------------------------------- +// HTTP Server +// --------------------------------------------------------------------------- + +let latestMetrics = null + +const server = createServer(async (req, res) => { + if (req.url === '/health' || req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ status: 'healthy', node: config.nodeName })) + return + } + + if (req.url === '/metrics') { + const metrics = latestMetrics || (await collectAllMetrics()) + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }) + res.end(formatPrometheusMetrics(metrics)) + return + } + + if (req.url === '/api/metrics') { + const metrics = latestMetrics || (await collectAllMetrics()) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(metrics)) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) +}) + +// --------------------------------------------------------------------------- +// Main Loop +// --------------------------------------------------------------------------- + +async function collectAndReport() { + try { + latestMetrics = await collectAllMetrics() + await reportMetrics(latestMetrics) + } catch (err) { + console.error('Collection error:', err.message) + } +} + +server.listen(config.port, '0.0.0.0', () => { + console.log(`[nomad-agent] Node: ${config.nodeName}`) + console.log(`[nomad-agent] Metrics server listening on port ${config.port}`) + console.log(`[nomad-agent] Collection interval: ${config.collectInterval / 1000}s`) + if (config.serverUrl) { + console.log(`[nomad-agent] Reporting to: ${config.serverUrl}`) + } else { + console.log('[nomad-agent] No NOMAD_SERVER_URL set — metrics available via /metrics endpoint only') + } + + // Initial collection + collectAndReport() + + // Periodic collection + setInterval(collectAndReport, config.collectInterval) +}) diff --git a/agent/healthcheck.mjs b/agent/healthcheck.mjs new file mode 100644 index 0000000..481a4d9 --- /dev/null +++ b/agent/healthcheck.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +// Simple healthcheck for the Nomad agent + +const port = process.env.AGENT_PORT || 9100 + +try { + const res = await fetch(`http://localhost:${port}/health`) + if (res.ok) { + process.exit(0) + } + process.exit(1) +} catch { + process.exit(1) +} diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..9f88183 --- /dev/null +++ b/agent/package.json @@ -0,0 +1,11 @@ +{ + "name": "nomad-agent", + "version": "1.0.0", + "description": "Project N.O.M.A.D. lightweight monitoring agent", + "type": "module", + "main": "agent.mjs", + "scripts": { + "start": "node agent.mjs" + }, + "license": "Apache-2.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d25eff1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,201 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Network Operations Monitoring and Automation Dashboard +# Docker Compose Stack for NAS and Homelab Environments +# ============================================================================= +# +# Usage: +# 1. Copy .env.example to .env and configure +# 2. docker compose up -d +# +# Compatible with: Unraid, TrueNAS SCALE, standard Docker hosts +# ============================================================================= + +name: project-nomad + +services: + # --------------------------------------------------------------------------- + # Nomad Application (Web UI + API) + # --------------------------------------------------------------------------- + nomad-app: + image: ghcr.io/crosstalk-solutions/project-nomad:latest + container_name: nomad-app + restart: unless-stopped + ports: + - "${PORT:-8080}:8080" + volumes: + - ${NOMAD_DATA_DIR:-./data}/storage:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + - ./install/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro + environment: + - NODE_ENV=${NODE_ENV:-production} + - PORT=8080 + - HOST=0.0.0.0 + - LOG_LEVEL=${LOG_LEVEL:-info} + - APP_KEY=${APP_KEY:?Set APP_KEY in .env} + - URL=${URL:-http://localhost:8080} + - DB_HOST=nomad-database + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-nomad} + - DB_USER=${DB_USER:-nomad} + - DB_PASSWORD=${DB_PASSWORD:?Set DB_PASSWORD in .env} + - DB_SSL=false + - REDIS_HOST=nomad-cache + - REDIS_PORT=6379 + - NOMAD_STORAGE_PATH=/app/storage + - NOMAD_API_URL=${NOMAD_API_URL:-} + - INTERNET_STATUS_TEST_URL=${INTERNET_STATUS_TEST_URL:-} + entrypoint: ["/usr/local/bin/entrypoint.sh"] + depends_on: + nomad-database: + condition: service_healthy + nomad-cache: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - nomad-internal + labels: + - "com.project-nomad.service=app" + - "com.project-nomad.description=Nomad Web Application" + + # --------------------------------------------------------------------------- + # Nomad Worker (Background Job Processing) + # --------------------------------------------------------------------------- + nomad-worker: + image: ghcr.io/crosstalk-solutions/project-nomad:latest + container_name: nomad-worker + restart: unless-stopped + volumes: + - ${NOMAD_DATA_DIR:-./data}/storage:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + environment: + - NODE_ENV=${NODE_ENV:-production} + - PORT=8081 + - HOST=0.0.0.0 + - LOG_LEVEL=${LOG_LEVEL:-info} + - APP_KEY=${APP_KEY} + - URL=${URL:-http://localhost:8080} + - DB_HOST=nomad-database + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-nomad} + - DB_USER=${DB_USER:-nomad} + - DB_PASSWORD=${DB_PASSWORD} + - DB_SSL=false + - REDIS_HOST=nomad-cache + - REDIS_PORT=6379 + - NOMAD_STORAGE_PATH=/app/storage + command: ["node", "ace", "queue:work", "--all"] + depends_on: + nomad-database: + condition: service_healthy + nomad-cache: + condition: service_healthy + networks: + - nomad-internal + deploy: + resources: + limits: + memory: 2G + labels: + - "com.project-nomad.service=worker" + - "com.project-nomad.description=Nomad Background Worker" + + # --------------------------------------------------------------------------- + # Database (MySQL 8.0) + # --------------------------------------------------------------------------- + nomad-database: + image: mysql:8.0 + container_name: nomad-database + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env} + - MYSQL_DATABASE=${DB_DATABASE:-nomad} + - MYSQL_USER=${DB_USER:-nomad} + - MYSQL_PASSWORD=${DB_PASSWORD} + volumes: + - nomad-db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - nomad-internal + deploy: + resources: + limits: + memory: 1G + labels: + - "com.project-nomad.service=database" + - "com.project-nomad.description=Nomad MySQL Database" + + # --------------------------------------------------------------------------- + # Cache / Queue (Redis 7) + # --------------------------------------------------------------------------- + nomad-cache: + image: redis:7-alpine + container_name: nomad-cache + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - ${NOMAD_DATA_DIR:-./data}/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 5s + retries: 5 + networks: + - nomad-internal + deploy: + resources: + limits: + memory: 512M + labels: + - "com.project-nomad.service=cache" + - "com.project-nomad.description=Nomad Redis Cache" + + # --------------------------------------------------------------------------- + # Nginx Reverse Proxy + # --------------------------------------------------------------------------- + nomad-nginx: + image: nginx:alpine + container_name: nomad-nginx + restart: unless-stopped + ports: + - "${NGINX_HTTP_PORT:-80}:80" + - "${NGINX_HTTPS_PORT:-443}:443" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ${NOMAD_DATA_DIR:-./data}/logs/nginx:/var/log/nginx + depends_on: + nomad-app: + condition: service_healthy + networks: + - nomad-internal + labels: + - "com.project-nomad.service=nginx" + - "com.project-nomad.description=Nomad Reverse Proxy" + +# ============================================================================= +# Networks +# ============================================================================= +networks: + nomad-internal: + driver: bridge + name: nomad-internal + +# ============================================================================= +# Volumes +# ============================================================================= +# Database uses a named Docker volume for optimal I/O performance. +# This avoids NFS/SMB latency issues common on NAS systems. +# All other data uses bind mounts configured via NOMAD_DATA_DIR. +volumes: + nomad-db-data: + driver: local diff --git a/homelab/reverse-proxy-examples/caddy.Caddyfile b/homelab/reverse-proxy-examples/caddy.Caddyfile new file mode 100644 index 0000000..81dbc6b --- /dev/null +++ b/homelab/reverse-proxy-examples/caddy.Caddyfile @@ -0,0 +1,49 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Caddy Reverse Proxy Configuration +# ============================================================================= +# +# Usage: +# 1. Replace "nomad.home.local" with your actual domain/hostname +# 2. Place this file in your Caddy configuration directory +# 3. Reload Caddy: caddy reload +# +# Caddy automatically provisions TLS certificates via Let's Encrypt +# when using a public domain. For local domains, it generates +# self-signed certificates automatically. +# ============================================================================= + +nomad.home.local { + # Reverse proxy to Nomad application + reverse_proxy nomad-app:8080 { + # Health checks + health_uri /api/health + health_interval 30s + health_timeout 5s + + # WebSocket support + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + } + + # Large file upload support (10GB) + request_body { + max_size 10GB + } + + # Access logging + log { + output file /var/log/caddy/nomad-access.log + format json + } + + # Security headers + header { + X-Frame-Options "SAMEORIGIN" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + } +} diff --git a/homelab/reverse-proxy-examples/nginx-proxy-manager.md b/homelab/reverse-proxy-examples/nginx-proxy-manager.md new file mode 100644 index 0000000..16fb853 --- /dev/null +++ b/homelab/reverse-proxy-examples/nginx-proxy-manager.md @@ -0,0 +1,87 @@ +# Nginx Proxy Manager Configuration for Project N.O.M.A.D. + +## Prerequisites + +- Nginx Proxy Manager installed and running +- Project N.O.M.A.D. stack running (via `docker compose up -d`) +- Both NPM and Nomad on the same Docker network, or using the host IP + +## Setup Steps + +### 1. Add Proxy Host + +1. Open Nginx Proxy Manager web UI (typically `http://your-server:81`) +2. Go to **Proxy Hosts** → **Add Proxy Host** + +### 2. Details Tab + +| Field | Value | +|-------|-------| +| **Domain Names** | `nomad.home.local` (or your domain) | +| **Scheme** | `http` | +| **Forward Hostname / IP** | `nomad-app` (if on same Docker network) or your host IP | +| **Forward Port** | `8080` | +| **Cache Assets** | Enabled | +| **Block Common Exploits** | Enabled | +| **Websockets Support** | **Enabled** (required for real-time updates) | + +### 3. SSL Tab (Optional) + +| Field | Value | +|-------|-------| +| **SSL Certificate** | Request a new certificate or select existing | +| **Force SSL** | Enabled | +| **HTTP/2 Support** | Enabled | + +### 4. Advanced Tab + +Add the following custom Nginx configuration: + +```nginx +# Large file upload support (10GB) +client_max_body_size 10G; + +# Timeout settings for large operations +proxy_read_timeout 600s; +proxy_send_timeout 600s; +proxy_connect_timeout 60s; + +# Forward real IP headers +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +``` + +### 5. Docker Network Configuration + +If Nginx Proxy Manager runs in Docker, ensure it can reach the Nomad containers. + +**Option A: Same Docker network** + +Add NPM to the `nomad-internal` network in your NPM docker-compose: + +```yaml +services: + npm: + # ... existing config ... + networks: + - nomad-internal + +networks: + nomad-internal: + external: true +``` + +**Option B: Host networking** + +Use your server's IP address instead of container names: + +- Forward Hostname: `192.168.1.100` (your server IP) +- Forward Port: `8080` + +## Verification + +After setup, visit your configured domain. You should see the Nomad dashboard. + +Check the health endpoint: `https://nomad.home.local/api/health` diff --git a/homelab/reverse-proxy-examples/traefik.yml b/homelab/reverse-proxy-examples/traefik.yml new file mode 100644 index 0000000..146abd9 --- /dev/null +++ b/homelab/reverse-proxy-examples/traefik.yml @@ -0,0 +1,65 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Traefik Reverse Proxy Configuration +# ============================================================================= +# +# Add these labels to your nomad-app service in docker-compose.yml +# when using Traefik as your reverse proxy. +# +# Prerequisites: +# - Traefik running with Docker provider enabled +# - A Traefik network (e.g., "traefik-public") +# +# Usage: +# Add to nomad-app service in docker-compose.yml: +# labels: (copy from below) +# networks: +# - nomad-internal +# - traefik-public +# +# ============================================================================= + +# Docker Compose labels for nomad-app service: +# +# labels: +# - "traefik.enable=true" +# # HTTP router +# - "traefik.http.routers.nomad.rule=Host(`nomad.home.local`)" +# - "traefik.http.routers.nomad.entrypoints=web" +# # HTTPS router (optional — uncomment for TLS) +# # - "traefik.http.routers.nomad-secure.rule=Host(`nomad.home.local`)" +# # - "traefik.http.routers.nomad-secure.entrypoints=websecure" +# # - "traefik.http.routers.nomad-secure.tls=true" +# # - "traefik.http.routers.nomad-secure.tls.certresolver=letsencrypt" +# # Service +# - "traefik.http.services.nomad.loadbalancer.server.port=8080" +# # WebSocket support +# - "traefik.http.middlewares.nomad-headers.headers.customrequestheaders.X-Forwarded-Proto=https" +# # Large file upload support +# - "traefik.http.middlewares.nomad-buffering.buffering.maxRequestBodyBytes=10737418240" + +# ============================================================================= +# Traefik dynamic configuration file (alternative to labels) +# Place in your Traefik dynamic config directory +# ============================================================================= + +http: + routers: + nomad: + rule: "Host(`nomad.home.local`)" + service: nomad + entryPoints: + - web + # Uncomment for TLS: + # tls: + # certResolver: letsencrypt + + services: + nomad: + loadBalancer: + servers: + - url: "http://nomad-app:8080" + healthCheck: + path: /api/health + interval: 30s + timeout: 5s diff --git a/homelab/truenas/Chart.yaml b/homelab/truenas/Chart.yaml new file mode 100644 index 0000000..f90414e --- /dev/null +++ b/homelab/truenas/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: project-nomad +description: >- + Project N.O.M.A.D. — Homelab Edition. + Network Operations Monitoring and Automation Dashboard. + Offline-first knowledge server with AI chat, information library, + education platform, offline maps, and homelab monitoring. +type: application +version: 1.0.0 +appVersion: "1.29.0" +home: https://github.com/DocwatZ/project-nomad-homelab-edition +icon: https://raw.githubusercontent.com/DocwatZ/project-nomad-homelab-edition/main/admin/public/favicons/favicon-96x96.png +sources: + - https://github.com/DocwatZ/project-nomad-homelab-edition +maintainers: + - name: DocwatZ + url: https://github.com/DocwatZ +keywords: + - nomad + - homelab + - monitoring + - offline + - knowledge-base + - nas + - truenas diff --git a/homelab/truenas/templates/deployment.yaml b/homelab/truenas/templates/deployment.yaml new file mode 100644 index 0000000..a1eb990 --- /dev/null +++ b/homelab/truenas/templates/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-app + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: app +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: app + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: app + spec: + containers: + - name: nomad-app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.app.port }} + protocol: TCP + env: + - name: NODE_ENV + value: {{ .Values.app.nodeEnv | quote }} + - name: PORT + value: {{ .Values.app.port | quote }} + - name: HOST + value: "0.0.0.0" + - name: LOG_LEVEL + value: {{ .Values.app.logLevel | quote }} + - name: APP_KEY + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-secrets + key: app-key + - name: URL + value: {{ .Values.app.url | quote }} + - name: DB_HOST + value: {{ .Release.Name }}-database + - name: DB_PORT + value: "3306" + - name: DB_DATABASE + value: {{ .Values.database.name | quote }} + - name: DB_USER + value: {{ .Values.database.user | quote }} + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-secrets + key: db-password + - name: DB_SSL + value: "false" + - name: REDIS_HOST + value: {{ .Release.Name }}-redis + - name: REDIS_PORT + value: "6379" + - name: NOMAD_STORAGE_PATH + value: "/app/storage" + volumeMounts: + - name: storage + mountPath: /app/storage + livenessProbe: + httpGet: + path: /api/health + port: {{ .Values.app.port }} + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: {{ .Values.app.port }} + initialDelaySeconds: 30 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources.app | nindent 12 }} + volumes: + - name: storage + {{- if .Values.storage.data.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.storage.data.existingClaim }} + {{- else if .Values.storage.data.hostPath }} + hostPath: + path: {{ .Values.storage.data.hostPath }} + type: DirectoryOrCreate + {{- else }} + persistentVolumeClaim: + claimName: {{ .Release.Name }}-data + {{- end }} diff --git a/homelab/truenas/templates/secrets.yaml b/homelab/truenas/templates/secrets.yaml new file mode 100644 index 0000000..4d8d3ba --- /dev/null +++ b/homelab/truenas/templates/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-secrets + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +type: Opaque +data: + app-key: {{ .Values.app.appKey | b64enc | quote }} + db-password: {{ .Values.database.password | b64enc | quote }} + db-root-password: {{ .Values.database.rootPassword | b64enc | quote }} diff --git a/homelab/truenas/templates/services.yaml b/homelab/truenas/templates/services.yaml new file mode 100644 index 0000000..1785681 --- /dev/null +++ b/homelab/truenas/templates/services.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-app + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: app +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.app.port }} + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: app +--- +{{- if .Values.database.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-database + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: database +spec: + type: ClusterIP + ports: + - port: 3306 + targetPort: 3306 + protocol: TCP + name: mysql + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: database +{{- end }} +--- +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-redis + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP + name: redis + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: redis +{{- end }} diff --git a/homelab/truenas/values.yaml b/homelab/truenas/values.yaml new file mode 100644 index 0000000..ec24c4c --- /dev/null +++ b/homelab/truenas/values.yaml @@ -0,0 +1,118 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# TrueNAS SCALE Helm Values +# ============================================================================= + +# Application image +image: + repository: ghcr.io/crosstalk-solutions/project-nomad + tag: latest + pullPolicy: Always + +# Replica count +replicaCount: 1 + +# Application settings +app: + port: 8080 + nodeEnv: production + logLevel: info + # Generate with: openssl rand -hex 32 + appKey: "" + # External URL for accessing Nomad + url: "http://localhost:8080" + +# Database settings +database: + enabled: true + image: + repository: mysql + tag: "8.0" + rootPassword: "" + name: nomad + user: nomad + password: "" + storage: + size: 10Gi + # TrueNAS storage class + storageClass: "" + +# Redis settings +redis: + enabled: true + image: + repository: redis + tag: 7-alpine + maxMemory: 256mb + storage: + size: 1Gi + storageClass: "" + +# Nginx reverse proxy +nginx: + enabled: true + image: + repository: nginx + tag: alpine + +# Storage +storage: + # Main data storage for Nomad content + data: + enabled: true + size: 100Gi + storageClass: "" + # Use an existing PVC + existingClaim: "" + # Host path (for TrueNAS ix-volumes) + hostPath: "" + + # Log storage + logs: + enabled: true + size: 5Gi + storageClass: "" + +# Service configuration +service: + type: ClusterIP + port: 80 + +# Ingress configuration +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: nomad.home.local + paths: + - path: / + pathType: Prefix + tls: [] + +# Resource limits +resources: + app: + limits: + memory: 2Gi + requests: + cpu: 250m + memory: 512Mi + worker: + limits: + memory: 2Gi + requests: + cpu: 100m + memory: 256Mi + database: + limits: + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + redis: + limits: + memory: 512Mi + requests: + cpu: 50m + memory: 64Mi diff --git a/homelab/unraid-template.xml b/homelab/unraid-template.xml new file mode 100644 index 0000000..ba9674f --- /dev/null +++ b/homelab/unraid-template.xml @@ -0,0 +1,46 @@ + + + ProjectNomad + ghcr.io/crosstalk-solutions/project-nomad:latest + https://github.com/crosstalk-solutions/project-nomad/pkgs/container/project-nomad + bridge + + bash + false + https://github.com/DocwatZ/project-nomad-homelab-edition/issues + https://github.com/DocwatZ/project-nomad-homelab-edition + + Project N.O.M.A.D. — Homelab Edition (Network Operations Monitoring and Automation Dashboard). + An offline-first knowledge server with AI chat, information library, education platform, + offline maps, and homelab monitoring. Requires MySQL 8.0 and Redis 7 containers. + + HomeAutomation: Tools: Productivity: + http://[IP]:[PORT:8080] + https://raw.githubusercontent.com/DocwatZ/project-nomad-homelab-edition/main/homelab/unraid-template.xml + https://raw.githubusercontent.com/DocwatZ/project-nomad-homelab-edition/main/admin/public/favicons/favicon-96x96.png + --restart unless-stopped + + + + + + + Requires separate MySQL 8.0 and Redis 7 containers. See documentation for full stack setup. + + 8080 + /mnt/user/appdata/project-nomad/storage + /var/run/docker.sock + production + + http://localhost:8080 + nomad-database + 3306 + nomad + nomad + + false + nomad-cache + 6379 + info + /app/storage + diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..a2f1de4 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,77 @@ +# ============================================================================= +# PROJECT N.O.M.A.D. — Homelab Edition +# Nginx Reverse Proxy Configuration +# ============================================================================= + +upstream nomad_app { + server nomad-app:8080; + keepalive 32; +} + +server { + listen 80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logging + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Client body size (for file uploads) + client_max_body_size 10G; + + # Timeouts for large file operations + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_connect_timeout 60s; + + # Health check endpoint (bypasses proxy for fast response) + location /nginx-health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # WebSocket support for Transmit (real-time updates) + location /__transmit { + proxy_pass http://nomad_app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + } + + # Main application + location / { + proxy_pass http://nomad_app; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Buffer settings for large responses + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 8 32k; + } + + # Static assets caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://nomad_app; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, immutable"; + } +}