From 5014d55d625e2c5c91ff6a84a1a7fc8994b40460 Mon Sep 17 00:00:00 2001 From: miczek Date: Fri, 13 Feb 2026 19:41:46 +0200 Subject: [PATCH] Initial homelab infrastructure Co-authored-by: Cursor --- .env.example | 29 ++++ .gitignore | 30 ++++ ADDITIONS_PLAN.md | 246 +++++++++++++++++++++++++++++ SECRETS_CHECKLIST.md | 20 +++ cups/docker-compose.yml | 27 ++++ dashboard/Dockerfile | 13 ++ dashboard/app.py | 116 ++++++++++++++ dashboard/docker-compose.yml | 21 +++ dashboard/requirements.txt | 2 + dashboard/templates/dashboard.html | 197 +++++++++++++++++++++++ docker-compose.yml | 22 +++ drone/docker-compose.yml | 38 +++++ gitea/docker-compose.yml | 19 +++ pihole/docker-compose.yml | 22 +++ registry/docker-compose.yml | 14 ++ setup-git-dual-remote.sh | 36 +++++ setup_dashboard.sh | 161 +++++++++++++++++++ setup_homelab.sh | 200 +++++++++++++++++++++++ start.sh | 21 +++ stop.sh | 4 + traefik/docker-compose.yml | 42 +++++ traefik/dynamic.yml.example | 20 +++ traefik/traefik.yml | 29 ++++ update.sh | 5 + uptime-kuma-setup.md | 57 +++++++ uptime-kuma/docker-compose.yml | 19 +++ watchtower/docker-compose.yml | 12 ++ wireguard/docker-compose.yml | 28 ++++ wol-api/Dockerfile | 14 ++ wol-api/app.py | 113 +++++++++++++ wol-api/docker-compose.yml | 17 ++ 31 files changed, 1594 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 ADDITIONS_PLAN.md create mode 100644 SECRETS_CHECKLIST.md create mode 100644 cups/docker-compose.yml create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/app.py create mode 100644 dashboard/docker-compose.yml create mode 100644 dashboard/requirements.txt create mode 100644 dashboard/templates/dashboard.html create mode 100644 docker-compose.yml create mode 100644 drone/docker-compose.yml create mode 100644 gitea/docker-compose.yml create mode 100644 pihole/docker-compose.yml create mode 100644 registry/docker-compose.yml create mode 100755 setup-git-dual-remote.sh create mode 100755 setup_dashboard.sh create mode 100755 setup_homelab.sh create mode 100755 start.sh create mode 100755 stop.sh create mode 100644 traefik/docker-compose.yml create mode 100644 traefik/dynamic.yml.example create mode 100644 traefik/traefik.yml create mode 100755 update.sh create mode 100644 uptime-kuma-setup.md create mode 100644 uptime-kuma/docker-compose.yml create mode 100644 watchtower/docker-compose.yml create mode 100644 wireguard/docker-compose.yml create mode 100644 wol-api/Dockerfile create mode 100644 wol-api/app.py create mode 100644 wol-api/docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bd9582 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and fill in. Never commit .env to git. + +# Let's Encrypt / Traefik +ACME_EMAIL= + +# Pi-hole admin password +PIHOLE_PASSWORD= + +# Traefik basic auth – generate: htpasswd -nb username password +# Then add to traefik/dynamic.yml middlewares.auth.basicAuth.users +TRAEFIK_AUTH_USERS= + +# WOL API & Dashboard +WOL_API_USER= +WOL_API_PASS= +# WEATHER_API_KEY= # OpenWeatherMap – optional for dashboard +# WEATHER_CITY= +# WOL_DEVICES=desktop:AA:BB:CC:DD:EE:FF:192.168.1.10 (name:mac:ip) +# WOL_BROADCAST_IP=192.168.0.255 + +# Drone CI (Gitea OAuth app, redirect https://drone.zea.lt/login) +DRONE_GITEA_CLIENT_ID= +DRONE_GITEA_CLIENT_SECRET= +DRONE_RPC_SECRET= +DRONE_ADMIN_USER= + +# CUPS printer admin +CUPSADMIN=admin +CUPSPASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13e8be9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Sensitive data – never commit +.env +traefik/letsencrypt/ +traefik/dynamic.yml +pihole/etc-pihole/ +pihole/etc-dnsmasq.d/ +wireguard/config/ +cups/config/ + +# Data dirs – keep docker-compose.yml, ignore runtime data +gitea/data/ +gitea/indexers/ +gitea/log/ +gitea/ssh/ +gitea/gitea/ +gitea/git/ +registry/* +!registry/docker-compose.yml +drone/* +!drone/docker-compose.yml +uptime-kuma/* +!uptime-kuma/docker-compose.yml + +# Backups (may contain old secrets) +*.bak +docker-compose.override.yml + +# Logs & temp +*.log +.DS_Store diff --git a/ADDITIONS_PLAN.md b/ADDITIONS_PLAN.md new file mode 100644 index 0000000..06ac997 --- /dev/null +++ b/ADDITIONS_PLAN.md @@ -0,0 +1,246 @@ +# Homelab Additions Plan: Kuma, Watchtower, Drone CI, CUPS Printer + +## Overview + +Add four new capabilities to the homelab: + +1. **Uptime Kuma** – Monitor service availability (HTTP, ping, etc.) +2. **Watchtower** – Auto-update container images +3. **Drone CI** – CI/CD for Gitea repos (server + Docker runner) +4. **CUPS** – USB printer sharing for remote printing + +--- + +## 1. Uptime Kuma + +- **Image:** `louislam/uptime-kuma:1` +- **Port:** 3001 +- **Data:** `./uptime-kuma:/app/data` +- **Traefik:** `kuma.zea.lt` with basic auth (same as other admin services) +- **Optional:** Mount `/var/run/docker.sock` for container monitoring + +--- + +## 2. Watchtower + +**Note:** Watchtower [is no longer maintained](https://github.com/containrrr/watchtower/discussions/2135). Alternatives: +- **WUD (What's Up Docker)** – Web UI, manual/controlled updates, notifications +- **Diun** – Notifications only (you decide when to update) + +If you still want Watchtower for automatic updates, it works but may not receive future fixes. + +- **Image:** `containrrr/watchtower` +- **Config:** Mount `/var/run/docker.sock`, run with `--schedule "0 0 4 * * *"` (daily at 4 AM) or `--interval 86400` +- **Optional:** `WATCHTOWER_CLEANUP=true` to remove old images +- **Label:** Add `com.centurylinklabs.watchtower.enable=false` to services you want to exclude (e.g. Traefik, WireGuard) + +--- + +## 3. Drone CI + +Requires **Gitea OAuth app** (manual step before starting Drone): + +1. In Gitea: **Settings → Applications → Create OAuth2 Application** +2. **Application Name:** Drone +3. **Redirect URI:** `https://drone.zea.lt/login` +4. **Confidential Client:** checked +5. Save and copy **Client ID** and **Client Secret** + +Components: + +### Drone Server + +- **Image:** `drone/drone:2` +- **Database:** SQLite in `/data` (simplest for homelab) +- **Env:** `DRONE_GITEA_*`, `DRONE_RPC_SECRET`, `DRONE_SERVER_HOST`, `DRONE_SERVER_PROTO` +- **Traefik:** `drone.zea.lt` + +### Drone Docker Runner + +- **Image:** `drone/drone-runner-docker:1` +- **Volume:** `/var/run/docker.sock` +- **Env:** `DRONE_RPC_HOST=drone`, `DRONE_RPC_PROTO=http`, `DRONE_RPC_SECRET=` +- **Network:** `homelab` (to reach Gitea, Registry) + +**Critical:** `DRONE_GITEA_SERVER` must be reachable from the Drone server. Use `https://git.zea.lt` (external URL) for OAuth; internal `http://gitea:3000` can cause callback issues when Gitea redirects. + +--- + +## 4. CUPS (USB Printer via Docker) + +- **Image:** `infra7/cups:latest` (supports arm64 for Raspberry Pi) +- **Device:** `--device /dev/bus/usb` +- **Ports:** `631:631` (IPP + web admin) +- **Env:** `CUPSADMIN`, `CUPSPASSWORD` (or Docker secrets) +- **Volume:** `./cups/config:/etc/cups` for persistence +- **Traefik:** `print.zea.lt` for web admin (optional) + +**Remote printing:** + +- Clients add printer: `ipp://YOUR_SERVER_IP:631/printers/` +- Or via `print.zea.lt` if you proxy IPP (less common) + +**USB setup:** + +1. Plug in printer +2. Run `lsusb` on host to verify +3. Ensure Docker can access `/dev/bus/usb` (permissions) +4. Open `http://YOUR_SERVER_IP:631` or `https://print.zea.lt` and add printer + +--- + +## DNS Updates + +Add to `pihole/etc-dnsmasq.d/02-custom.conf`: + +``` +address=/kuma.zea.lt/YOUR_SERVER_IP +address=/drone.zea.lt/YOUR_SERVER_IP +address=/print.zea.lt/YOUR_SERVER_IP +``` + +--- + +## Docker Compose Additions (draft) + +```yaml + ### 📊 Uptime Kuma ### + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + restart: always + volumes: + - ./uptime-kuma:/app/data + - /var/run/docker.sock:/var/run/docker.sock # optional: monitor containers + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.kuma.rule=Host(`kuma.zea.lt`)" + - "traefik.http.routers.kuma.entrypoints=websecure" + - "traefik.http.routers.kuma.tls.certresolver=leresolver" + - "traefik.http.routers.kuma.middlewares=auth" + - "traefik.http.services.kuma.loadbalancer.server.port=3001" + + ### 🔄 Watchtower ### + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WATCHTOWER_SCHEDULE=0 0 4 * * * + - WATCHTOWER_CLEANUP=true + networks: + - homelab + + ### 🚀 Drone CI ### + drone-server: + image: drone/drone:2 + container_name: drone + restart: always + environment: + - DRONE_GITEA_SERVER=https://git.zea.lt + - DRONE_GITEA_CLIENT_ID=${DRONE_GITEA_CLIENT_ID} + - DRONE_GITEA_CLIENT_SECRET=${DRONE_GITEA_CLIENT_SECRET} + - DRONE_RPC_SECRET=${DRONE_RPC_SECRET} + - DRONE_SERVER_HOST=drone.zea.lt + - DRONE_SERVER_PROTO=https + - DRONE_USER_CREATE=username:YOUR_GITEA_USER,admin:true # first admin + volumes: + - ./drone:/data + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.drone.rule=Host(`drone.zea.lt`)" + - "traefik.http.routers.drone.entrypoints=websecure" + - "traefik.http.routers.drone.tls.certresolver=leresolver" + - "traefik.http.routers.drone.middlewares=auth" + - "traefik.http.services.drone.loadbalancer.server.port=80" + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DRONE_RPC_HOST=drone + - DRONE_RPC_PROTO=http + - DRONE_RPC_SECRET=${DRONE_RPC_SECRET} + - DRONE_RUNNER_CAPACITY=2 + networks: + - homelab + + ### 🖨️ CUPS Printer Server ### + cups: + image: infra7/cups:latest + container_name: cups + restart: always + ulimits: + nofile: + soft: 65536 + hard: 65536 + devices: + - /dev/bus/usb:/dev/bus/usb + environment: + - TZ=Europe/London + - CUPSADMIN=${CUPSADMIN:-admin} + - CUPSPASSWORD=${CUPSPASSWORD} + volumes: + - ./cups/config:/etc/cups + ports: + - "631:631" + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.print.rule=Host(`print.zea.lt`)" + - "traefik.http.routers.print.entrypoints=websecure" + - "traefik.http.routers.print.tls.certresolver=leresolver" + - "traefik.http.services.print.loadbalancer.server.port=631" +``` + +--- + +## .env Additions + +``` +# Drone CI (create OAuth app in Gitea first) +DRONE_GITEA_CLIENT_ID=your-client-id +DRONE_GITEA_CLIENT_SECRET=your-client-secret +DRONE_RPC_SECRET= + +# CUPS printer admin +CUPSADMIN=admin +CUPSPASSWORD=your-secure-password +``` + +--- + +## Implementation Order + +1. Add DNS entries for `kuma.zea.lt`, `drone.zea.lt`, `print.zea.lt` +2. Create directories: `mkdir -p uptime-kuma drone cups/config` +3. Add services to `docker-compose.yml` +4. Add secrets to `.env` +5. Create Gitea OAuth app +6. `docker compose up -d` for new services +7. Plug in USB printer, access CUPS at `https://print.zea.lt` or `:631`, add printer +8. Open Drone at `https://drone.zea.lt`, activate repos +9. Configure Uptime Kuma monitors for your services + +--- + +## Watchtower Exclusions (optional) + +To prevent Watchtower from updating critical services (Traefik, WireGuard), add: + +```yaml +labels: + - "com.centurylinklabs.watchtower.enable=false" +``` + +To services: `traefik`, `wireguard`, optionally `pihole`. diff --git a/SECRETS_CHECKLIST.md b/SECRETS_CHECKLIST.md new file mode 100644 index 0000000..599a52f --- /dev/null +++ b/SECRETS_CHECKLIST.md @@ -0,0 +1,20 @@ +# Secrets Checklist – Never Commit + +Before pushing to git, ensure these are **only** in `.env` (gitignored): + +| Variable | Used by | Example | +|----------|---------|---------| +| `ACME_EMAIL` | Traefik | Your email for Let's Encrypt | +| `PIHOLE_PASSWORD` | Pi-hole | Web UI password | +| `TRAEFIK_AUTH_USERS` | traefik/dynamic.yml | `htpasswd -nb user pass` | +| `WOL_API_USER` / `WOL_API_PASS` | Dashboard, WOL API | Basic auth | +| `WOL_DEVICES` | WOL API | `desktop:MAC:IP` format | +| `DRONE_GITEA_CLIENT_ID` / `SECRET` | Drone | OAuth from Gitea | +| `DRONE_RPC_SECRET` | Drone | `openssl rand -hex 16` | +| `DRONE_ADMIN_USER` | Drone | Your Gitea username | +| `CUPSADMIN` / `CUPSPASSWORD` | CUPS | Printer admin | +| `WEATHER_API_KEY` / `WEATHER_CITY` | Dashboard | OpenWeatherMap (optional) | + +**Files always gitignored:** `.env`, `traefik/dynamic.yml`, `traefik/letsencrypt/`, data dirs (gitea, pihole, etc.) + +**Verify before push:** `git status` – `.env` and `traefik/dynamic.yml` must not appear. diff --git a/cups/docker-compose.yml b/cups/docker-compose.yml new file mode 100644 index 0000000..2d93c39 --- /dev/null +++ b/cups/docker-compose.yml @@ -0,0 +1,27 @@ +services: + cups: + image: infra7/cups:latest + container_name: cups + restart: always + ulimits: + nofile: + soft: 65536 + hard: 65536 + devices: + - /dev/bus/usb:/dev/bus/usb + environment: + - TZ=Europe/London + - CUPSADMIN=${CUPSADMIN:-admin} + - CUPSPASSWORD=${CUPSPASSWORD} + volumes: + - ./config:/etc/cups + ports: + - "6363:631" + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.print.rule=Host(`print.zea.lt`)" + - "traefik.http.routers.print.entrypoints=websecure" + - "traefik.http.routers.print.tls.certresolver=leresolver" + - "traefik.http.services.print.loadbalancer.server.port=631" diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..7fbefce --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + + +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD ["python", "app.py"] diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..890bcbd --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,116 @@ +from flask import Flask, render_template, jsonify, request, Response +import requests +import os +from functools import wraps +from requests.auth import HTTPBasicAuth + +app = Flask(__name__) + +WOL_API_URL = os.getenv("WOL_API_URL", "https://wol.zea.lt") +WOL_API_USER = os.getenv("WOL_API_USER") +WOL_API_PASS = os.getenv("WOL_API_PASS") +WEATHER_API_KEY = os.getenv("WEATHER_API_KEY") +WEATHER_CITY = os.getenv("WEATHER_CITY", "") + + +def _fetch_wol_status(): + """Fetch device list and status from WOL API (single source of truth).""" + try: + auth = ( + HTTPBasicAuth(WOL_API_USER, WOL_API_PASS) + if WOL_API_USER and WOL_API_PASS + else None + ) + r = requests.get( + f"{WOL_API_URL}/status", + auth=auth, + timeout=5, + ) + if r.ok: + return r.json().get("devices", {}) + except requests.RequestException: + pass + return {} + + +def check_auth(username, password): + return username == WOL_API_USER and password == WOL_API_PASS + + +def authenticate(): + """Sends a 401 response that enables basic auth.""" + return Response( + "Could not verify your access level for that URL.\n" + "You have to login with proper credentials", + 401, + {"WWW-Authenticate": 'Basic realm="Login Required"'}, + ) + + +def requires_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + return f(*args, **kwargs) + + return decorated + + +@app.route("/") +def dashboard(): + devices_data = _fetch_wol_status() + devices = {k: v.get("mac", "") for k, v in devices_data.items()} + status = {k: v.get("online", False) for k, v in devices_data.items()} + return render_template( + "dashboard.html", + devices=devices, + status=status, + ) + + +@app.route("/wake/", methods=["POST"]) +@requires_auth +def wake_device(device): + devices_data = _fetch_wol_status() + if device not in devices_data: + return jsonify({"error": "Device not found"}), 404 + try: + auth = HTTPBasicAuth(WOL_API_USER, WOL_API_PASS) + r = requests.post( + f"{WOL_API_URL}/wake/{device}", + auth=auth, + timeout=5, + ) + if r.ok: + return jsonify({"message": f"Wake command sent to {device}"}) + return jsonify({"error": f"Failed to send wake command: {r.text}"}), r.status_code + except requests.RequestException as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/weather") +def weather(): + """Proxy for OpenWeatherMap – keeps API key server-side.""" + if not WEATHER_API_KEY or not WEATHER_CITY: + return jsonify({"error": "Weather not configured"}), 503 + try: + r = requests.get( + "https://api.openweathermap.org/data/2.5/weather", + params={ + "q": WEATHER_CITY, + "appid": WEATHER_API_KEY, + "units": "metric", + }, + timeout=5, + ) + if r.ok: + return jsonify(r.json()) + except requests.RequestException: + pass + return jsonify({"error": "Weather unavailable"}), 503 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml new file mode 100644 index 0000000..e73f584 --- /dev/null +++ b/dashboard/docker-compose.yml @@ -0,0 +1,21 @@ +services: + dashboard: + build: . + container_name: dashboard + restart: always + environment: + - WOL_API_URL=${WOL_API_URL:-https://wol.zea.lt} + - WOL_API_USER=${WOL_API_USER} + - WOL_API_PASS=${WOL_API_PASS} + - WEATHER_API_KEY=${WEATHER_API_KEY} + - WEATHER_CITY=${WEATHER_CITY} + networks: + - homelab + ports: + - "8080:8080" + labels: + - "traefik.enable=true" + - "traefik.http.routers.dashboard.rule=Host(`zea.lt`)" + - "traefik.http.routers.dashboard.entrypoints=websecure" + - "traefik.http.routers.dashboard.tls.certresolver=leresolver" + - "traefik.http.routers.dashboard.middlewares=auth@file" diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..4be88a2 --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1,2 @@ +Flask==2.3.2 +requests==2.31.0 diff --git a/dashboard/templates/dashboard.html b/dashboard/templates/dashboard.html new file mode 100644 index 0000000..430d47e --- /dev/null +++ b/dashboard/templates/dashboard.html @@ -0,0 +1,197 @@ + + + + + + HomeLab Dashboard + + + + + +
+
+
+
Loading weather...
+ + + +
+

Services

+ +
+ +
+

Devices

+
+ {% for dev, mac in devices.items() %} +
+
+ {% if status[dev] %} + 🟢 + {% else %} + 🔴 + {% endif %} +
+
{{ dev|capitalize }}
+ +
+ {% endfor %} +
+
+
+ + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd106f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# Homelab aggregator – runs all per-folder compose files +# +# Usage: +# From root (all services): docker compose up -d +# Single folder: cd traefik && docker compose up -d +# (create network first: docker network create homelab) +# +# Requires: Docker Compose v2.20+ (include support) +# Network "homelab" is created by traefik (must start traefik first for others) + +include: + - path: traefik/docker-compose.yml + - path: gitea/docker-compose.yml + - path: registry/docker-compose.yml + - path: wol-api/docker-compose.yml + - path: pihole/docker-compose.yml + - path: wireguard/docker-compose.yml + - path: dashboard/docker-compose.yml + - path: uptime-kuma/docker-compose.yml + - path: watchtower/docker-compose.yml + - path: drone/docker-compose.yml + - path: cups/docker-compose.yml diff --git a/drone/docker-compose.yml b/drone/docker-compose.yml new file mode 100644 index 0000000..fab45e3 --- /dev/null +++ b/drone/docker-compose.yml @@ -0,0 +1,38 @@ +services: + drone: + image: drone/drone:2 + container_name: drone + restart: always + environment: + - DRONE_GITEA_SERVER=https://git.zea.lt + - DRONE_GITEA_CLIENT_ID=${DRONE_GITEA_CLIENT_ID} + - DRONE_GITEA_CLIENT_SECRET=${DRONE_GITEA_CLIENT_SECRET} + - DRONE_RPC_SECRET=${DRONE_RPC_SECRET} + - DRONE_SERVER_HOST=drone.zea.lt + - DRONE_SERVER_PROTO=https + - DRONE_USER_CREATE=username:${DRONE_ADMIN_USER},admin:true + volumes: + - .:/data + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.drone.rule=Host(`drone.zea.lt`)" + - "traefik.http.routers.drone.entrypoints=websecure" + - "traefik.http.routers.drone.tls.certresolver=leresolver" + - "traefik.http.routers.drone.middlewares=auth@file" + - "traefik.http.services.drone.loadbalancer.server.port=80" + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DRONE_RPC_HOST=drone + - DRONE_RPC_PROTO=http + - DRONE_RPC_SECRET=${DRONE_RPC_SECRET} + - DRONE_RUNNER_CAPACITY=2 + networks: + - homelab diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100644 index 0000000..854a071 --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,19 @@ +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: always + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__server__ROOT_URL=https://git.zea.lt + volumes: + - .:/data + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.gitea.rule=Host(`git.zea.lt`)" + - "traefik.http.routers.gitea.entrypoints=websecure" + - "traefik.http.routers.gitea.tls.certresolver=leresolver" + - "traefik.http.services.gitea.loadbalancer.server.port=3000" diff --git a/pihole/docker-compose.yml b/pihole/docker-compose.yml new file mode 100644 index 0000000..ae3b663 --- /dev/null +++ b/pihole/docker-compose.yml @@ -0,0 +1,22 @@ +services: + pihole: + image: pihole/pihole:latest + container_name: pihole + restart: always + network_mode: host + environment: + - TZ=Europe/London + - WEBPASSWORD=${PIHOLE_PASSWORD:-admin} + - WEBPORT=8080 + - DNSMASQ_LISTENING=all + volumes: + - ./etc-pihole:/etc/pihole + - ./etc-dnsmasq.d:/etc/dnsmasq.d + labels: + - "traefik.enable=true" + - "traefik.http.routers.pihole.rule=Host(`pihole.zea.lt`)" + - "traefik.http.routers.pihole.entrypoints=websecure" + - "traefik.http.routers.pihole.tls.certresolver=leresolver" + - "traefik.http.routers.pihole.middlewares=pihole-prefix" + - "traefik.http.middlewares.pihole-prefix.addprefix.prefix=/admin" + - "traefik.http.services.pihole.loadbalancer.server.port=8080" diff --git a/registry/docker-compose.yml b/registry/docker-compose.yml new file mode 100644 index 0000000..d88ad1e --- /dev/null +++ b/registry/docker-compose.yml @@ -0,0 +1,14 @@ +services: + registry: + image: registry:2 + container_name: registry + restart: always + volumes: + - .:/var/lib/registry + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.registry.rule=Host(`registry.zea.lt`)" + - "traefik.http.routers.registry.entrypoints=websecure" + - "traefik.http.routers.registry.tls.certresolver=leresolver" diff --git a/setup-git-dual-remote.sh b/setup-git-dual-remote.sh new file mode 100755 index 0000000..af70fcf --- /dev/null +++ b/setup-git-dual-remote.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Configure homelab repo to push to both Gitea (local) and remote (e.g. GitHub) +# Run once after: git init && git add . && git commit -m "Initial homelab config" +# +# Usage: ./setup-git-dual-remote.sh [gitea_url] [remote_url] +# Example: ./setup-git-dual-remote.sh https://git.zea.lt/USER/homelab.git https://github.com/USER/homelab.git + +set -e + +GITEA_URL="${1:?Usage: $0 }" +REMOTE_URL="${2:?Usage: $0 }" + +cd "$(dirname "$0")" + +if [[ ! -d .git ]]; then + echo "Initializing git..." + git init + git add . + git commit -m "Initial homelab infrastructure" || true +fi + +# Remove default origin if it exists (e.g. from clone) +git remote remove origin 2>/dev/null || true + +# Add origin with dual push URLs – one push goes to both +git remote add origin "$GITEA_URL" +git remote set-url --add --push origin "$GITEA_URL" +git remote set-url --add --push origin "$REMOTE_URL" + +echo "Dual remote configured." +echo " Local (Gitea): $GITEA_URL" +echo " Remote backup: $REMOTE_URL" +echo "" +echo "Create both repos first (empty, no readme). Then:" +echo " git branch -M main" +echo " git push -u origin main" diff --git a/setup_dashboard.sh b/setup_dashboard.sh new file mode 100755 index 0000000..e9d7b3b --- /dev/null +++ b/setup_dashboard.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +set -e + +BASE_DIR="$HOME/homelab" +DASHBOARD_DIR="$BASE_DIR/dashboard" +TEMPLATES_DIR="$DASHBOARD_DIR/templates" +DOCKER_COMPOSE_FILE="$BASE_DIR/docker-compose.yml" + +echo "Creating dashboard directory structure..." +mkdir -p "$TEMPLATES_DIR" + +echo "Writing app.py..." +cat > "$DASHBOARD_DIR/app.py" << 'EOF' +from flask import Flask, render_template, jsonify, request +import subprocess +import requests + +app = Flask(__name__) + +DEVICES = { + "desktop": "AA:BB:CC:DD:EE:FF", # Replace with real MAC +} + +DEVICE_IPS = { + "desktop": "192.168.1.10", # Replace with real IP +} + +WOL_API_URL = "https://wol.zea.lt" + +def ping_host(ip): + try: + subprocess.check_output(['ping', '-c', '1', '-W', '1', ip]) + return True + except subprocess.CalledProcessError: + return False + +@app.route('/') +def dashboard(): + status = {dev: ping_host(ip) for dev, ip in DEVICE_IPS.items()} + return render_template('dashboard.html', devices=DEVICES, status=status) + +@app.route('/wake/', methods=['POST']) +def wake_device(device): + if device not in DEVICES: + return jsonify({"error": "Device not found"}), 404 + try: + r = requests.post(f"{WOL_API_URL}/wake/{device}") + if r.ok: + return jsonify({"message": f"Wake command sent to {device}"}) + else: + return jsonify({"error": f"Failed to send wake command: {r.text}"}), r.status_code + except Exception as e: + return jsonify({"error": str(e)}), 500 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +EOF + +echo "Writing dashboard.html template..." +cat > "$TEMPLATES_DIR/dashboard.html" << 'EOF' + + + + HomeLab Dashboard + + + +

HomeLab Dashboard

+ +

Services

+ + +

Devices

+ + + + + + + + + + {% for device, mac in devices.items() %} + + + + + + {% endfor %} + +
DeviceStatusAction
{{ device }} + {% if status[device] %} + Online + {% else %} + Offline + {% endif %} + + +
+ + + + +EOF + +echo "Writing Dockerfile..." +cat > "$DASHBOARD_DIR/Dockerfile" << 'EOF' +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD ["python", "app.py"] +EOF + +echo "Writing requirements.txt..." +cat > "$DASHBOARD_DIR/requirements.txt" << 'EOF' +Flask==2.3.2 +requests==2.31.0 +EOF + +# Backup docker-compose.yml just in case +cp "$DOCKER_COMPOSE_FILE" "$DOCKER_COMPOSE_FILE.bak" + +echo "Updating docker-compose.yml to add dashboard service..." +# Append dashboard service before the last line if it ends with '...' or just at end +# If docker-compose.yml uses version 3+ it should be fine +cat >> "$DOCKER_COMPOSE_FILE" << 'EOF' + + dashboard: + build: ./dashboard + container_name: dashboard + restart: always + labels: + - "traefik.enable=true" + - "traefik.http.routers.dashboard.rule=Host(`dashboard.zea.lt`)" + - "traefik.http.routers.dashboard.entrypoints=websecure" + - "traefik.http.routers.dashboard.tls.certresolver=leresolver" +EOF + +echo "Setup complete! You can now run:" +echo "cd $BASE_DIR && docker-compose build dashboard && docker-compose up -d dashboard" + diff --git a/setup_homelab.sh b/setup_homelab.sh new file mode 100755 index 0000000..9f262d8 --- /dev/null +++ b/setup_homelab.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +# Homelab Setup Script +echo "🏠 Setting up homelab directories and files..." + +# Create main directories +mkdir -p traefik/letsencrypt +mkdir -p gitea +mkdir -p registry +mkdir -p wol-api +mkdir -p pihole/etc-pihole +mkdir -p pihole/etc-dnsmasq.d +mkdir -p wireguard/config + +# Set proper permissions +chmod 600 traefik/letsencrypt +chmod 755 gitea registry pihole/etc-pihole pihole/etc-dnsmasq.d wireguard/config + +# Create .env file +cat > .env << 'EOF' +# Pi-hole admin password +PIHOLE_PASSWORD=your_secure_password_here + +# Change this to a secure password! +EOF + +# Create basic traefik.yml config +cat > traefik/traefik.yml << 'EOF' +# Static configuration +api: + dashboard: true + insecure: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + address: ":443" + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + +certificatesResolvers: + leresolver: + acme: + httpChallenge: + entryPoint: web + email: your-email@example.com + storage: /letsencrypt/acme.json +EOF + +# Create basic WOL API Dockerfile +cat > wol-api/Dockerfile << 'EOF' +FROM python:3.9-slim + +WORKDIR /app + +# Install wake-on-lan package +RUN pip install flask wakeonlan + +COPY app.py . + +EXPOSE 5000 + +CMD ["python", "app.py"] +EOF + +# Create basic WOL API application +cat > wol-api/app.py << 'EOF' +from flask import Flask, request, jsonify +from wakeonlan import send_magic_packet +import logging + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) + +# Define your devices here +DEVICES = { + "desktop": "00:11:22:33:44:55", # Replace with actual MAC addresses + "laptop": "AA:BB:CC:DD:EE:FF", + # Add more devices as needed +} + +@app.route('/wake/', methods=['POST']) +def wake_device(device): + if device not in DEVICES: + return jsonify({"error": "Device not found"}), 404 + + try: + mac_address = DEVICES[device] + send_magic_packet(mac_address) + app.logger.info(f"Magic packet sent to {device} ({mac_address})") + return jsonify({"message": f"Wake-on-LAN packet sent to {device}"}), 200 + except Exception as e: + app.logger.error(f"Error sending wake packet: {e}") + return jsonify({"error": "Failed to send wake packet"}), 500 + +@app.route('/devices', methods=['GET']) +def list_devices(): + return jsonify({"devices": list(DEVICES.keys())}) + +@app.route('/health', methods=['GET']) +def health_check(): + return jsonify({"status": "healthy"}), 200 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) +EOF + +# Create Pi-hole custom DNS config +cat > pihole/etc-dnsmasq.d/02-custom.conf << 'EOF' +# Allow queries from local network +interface=eth0 +listen-address=127.0.0.1,0.0.0.0 +bind-interfaces + +# Custom DNS entries for your homelab +# Format: address=/domain.name/IP +address=/traefik.zea.lt/192.168.1.100 +address=/git.zea.lt/192.168.1.100 +address=/registry.zea.lt/192.168.1.100 +address=/wol.zea.lt/192.168.1.100 +address=/pihole.zea.lt/192.168.1.100 +EOF + +# Create startup script +cat > start.sh << 'EOF' +#!/bin/bash +echo "🚀 Starting homelab services..." +docker-compose up -d +echo "✅ Services started!" +echo "" +echo "🔗 Access points:" +echo " - Traefik Dashboard: https://traefik.zea.lt" +echo " - Gitea: https://git.zea.lt" +echo " - Pi-hole: https://pihole.zea.lt" +echo " - Docker Registry: https://registry.zea.lt" +echo " - WOL API: https://wol.zea.lt" +echo "" +echo "📋 Next steps:" +echo " 1. Check WireGuard logs: docker logs wireguard" +echo " 2. Get VPN configs from: ./wireguard/config/peer1/" +echo " 3. Update .env with secure passwords" +echo " 4. Update MAC addresses in wol-api/app.py" +echo " 5. Update server IP in pihole/etc-dnsmasq.d/02-custom.conf" +EOF + +# Create stop script +cat > stop.sh << 'EOF' +#!/bin/bash +echo "🛑 Stopping homelab services..." +docker-compose down +echo "✅ Services stopped!" +EOF + +# Create update script +cat > update.sh << 'EOF' +#!/bin/bash +echo "🔄 Updating homelab services..." +docker-compose pull +docker-compose up -d +echo "✅ Services updated!" +EOF + +# Make scripts executable +chmod +x start.sh stop.sh update.sh + +# Create .gitignore +cat > .gitignore << 'EOF' +# Sensitive data +.env +traefik/letsencrypt/ +gitea/ +registry/ +pihole/etc-pihole/ +wireguard/config/ + +# Logs +*.log +EOF + +echo "✅ Homelab setup complete!" +echo "" +echo "📝 TODO before starting:" +echo " 1. Update .env with secure passwords" +echo " 2. Update MAC addresses in wol-api/app.py" +echo " 3. Update server IP in pihole/etc-dnsmasq.d/02-custom.conf" +echo " 4. Make sure your domains (*.zea.lt) point to your server" +echo "" +echo "🚀 To start: ./start.sh" +echo "🛑 To stop: ./stop.sh" +echo "🔄 To update: ./update.sh" diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..a201a0f --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/bin/bash +echo "🚀 Starting homelab services..." +docker compose up -d +echo "✅ Services started!" +echo "" +echo "🔗 Access points:" +echo " - Traefik Dashboard: https://traefik.zea.lt" +echo " - Gitea: https://git.zea.lt" +echo " - Pi-hole: https://pihole.zea.lt" +echo " - Docker Registry: https://registry.zea.lt" +echo " - WOL API: https://wol.zea.lt" +echo " - Uptime Kuma: https://kuma.zea.lt" +echo " - Drone CI: https://drone.zea.lt" +echo " - CUPS Printer: https://print.zea.lt (IPP: port 6363)" +echo "" +echo "📋 Next steps:" +echo " 1. Check WireGuard logs: docker logs wireguard" +echo " 2. Get VPN configs from: ./wireguard/config/peer1/" +echo " 3. Update .env with secure passwords" +echo " 4. Update MAC addresses in wol-api/app.py" +echo " 5. Update server IP in pihole/etc-dnsmasq.d/02-custom.conf" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..9d33cb2 --- /dev/null +++ b/stop.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "🛑 Stopping homelab services..." +docker compose down +echo "✅ Services stopped!" diff --git a/traefik/docker-compose.yml b/traefik/docker-compose.yml new file mode 100644 index 0000000..ff1460f --- /dev/null +++ b/traefik/docker-compose.yml @@ -0,0 +1,42 @@ +services: + traefik: + image: traefik:v2.11 + container_name: traefik + restart: always + command: + - "--api.dashboard=true" + - "--api.insecure=false" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=homelab" + - "--providers.file.filename=/etc/traefik/dynamic.yml" + - "--providers.file.watch=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.leresolver.acme.httpchallenge=true" + - "--certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.leresolver.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.leresolver.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./letsencrypt:/letsencrypt" + - "./traefik.yml:/traefik.yml:ro" + - "./dynamic.yml:/etc/traefik/dynamic.yml:ro" + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.zea.lt`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls.certresolver=leresolver" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=auth@file" + - "com.centurylinklabs.watchtower.enable=false" + networks: + - homelab + +networks: + homelab: + name: homelab + driver: bridge diff --git a/traefik/dynamic.yml.example b/traefik/dynamic.yml.example new file mode 100644 index 0000000..10a2d33 --- /dev/null +++ b/traefik/dynamic.yml.example @@ -0,0 +1,20 @@ +# Copy to dynamic.yml and add your htpasswd line. +# Generate: htpasswd -nb username password + +http: + routers: + traefik-dashboard: + rule: "Host(`traefik.zea.lt`)" + entryPoints: + - websecure + service: api@internal + tls: + certResolver: leresolver + middlewares: + - auth + + middlewares: + auth: + basicAuth: + users: + - "USERNAME:$apr1$HASH$YOUR_HASH_HERE" diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..b2aa372 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,29 @@ +# traefik/traefik.yml +api: + dashboard: true + insecure: false + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + + file: + filename: /etc/traefik/dynamic.yml + watch: true + +certificatesResolvers: + leresolver: + acme: + # Email set via traefik/docker-compose command (ACME_EMAIL env) + email: placeholder@example.com + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web + diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..965e418 --- /dev/null +++ b/update.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo "🔄 Updating homelab services..." +docker-compose pull +docker-compose up -d +echo "✅ Services updated!" diff --git a/uptime-kuma-setup.md b/uptime-kuma-setup.md new file mode 100644 index 0000000..4f2899f --- /dev/null +++ b/uptime-kuma-setup.md @@ -0,0 +1,57 @@ +# Uptime Kuma Setup Guide + +Access Uptime Kuma at **https://kuma.zea.lt** (use Traefik auth: your-username / your-password). + +## Why Internal URLs? + +Public URLs (https://zea.lt, etc.) are behind Traefik basic auth. Uptime Kuma's health checks get **401 Unauthorized** when using those. Use **internal container URLs** instead—no auth, same reliability. + +--- + +## First-Time Setup + +1. Open https://kuma.zea.lt +2. Create your Uptime Kuma admin account (username + password) +3. Click **Add New Monitor** for each service below + +--- + +## Recommended Monitors (Internal URLs – No Auth) + +| # | Monitor Name | Type | URL/Target | Heartbeat | +|---|--------------|----------|---------------------------------|-----------| +| 1 | Dashboard | HTTP(s) | http://dashboard:8080 | 60 | +| 2 | Gitea | HTTP(s) | http://gitea:3000 | 60 | +| 3 | Docker Registry | HTTP(s) | http://registry:5000 | 60 | +| 4 | Drone CI | HTTP(s) | http://drone:80 | 60 | +| 5 | CUPS Printer | HTTP(s) | http://cups:631 | 300 | +| 6 | WOL API | HTTP(s) | http://host.docker.internal:5000/health | 60 | +| 7 | Pi-hole DNS | TCP Port | host.docker.internal / 53 | 60 | +| 8 | Traefik HTTP | TCP Port | traefik / 80 | 60 | +| 9 | Traefik HTTPS| TCP Port | traefik / 443 | 60 | +|10 | HomeLab Server | Ping | 192.168.1.100 | 60 | +|11 | Desktop PC | Ping | 192.168.1.10 | 60 | + +**Note:** For TCP Port monitors (#8, #9), use **Monitor Type: TCP Port**, Host: `traefik`, Port: `80` or `443`. + +--- + +## Step-by-Step (Add Monitor) + +1. Click **+ Add New Monitor** +2. **Monitor Type:** HTTP(s) (or TCP Port / Ping) +3. **Friendly Name:** e.g. "Gitea" +4. **URL:** e.g. `http://gitea:3000` +5. **Heartbeat Interval:** 60 (or 300 for CUPS) +6. **Retries:** 2 +7. For HTTP(s): **Accepted Status Codes** 200–299 (default) +8. Click **Save** + +--- + +## Optional: Notifications + +Settings → Notifications → Add: +- **Email** – for downtime alerts +- **Telegram** – if you use Telegram +- **Webhook** – for custom integrations diff --git a/uptime-kuma/docker-compose.yml b/uptime-kuma/docker-compose.yml new file mode 100644 index 0000000..7628a0b --- /dev/null +++ b/uptime-kuma/docker-compose.yml @@ -0,0 +1,19 @@ +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + restart: always + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - .:/app/data + - /var/run/docker.sock:/var/run/docker.sock + networks: + - homelab + labels: + - "traefik.enable=true" + - "traefik.http.routers.kuma.rule=Host(`kuma.zea.lt`)" + - "traefik.http.routers.kuma.entrypoints=websecure" + - "traefik.http.routers.kuma.tls.certresolver=leresolver" + - "traefik.http.routers.kuma.middlewares=auth@file" + - "traefik.http.services.kuma.loadbalancer.server.port=3001" diff --git a/watchtower/docker-compose.yml b/watchtower/docker-compose.yml new file mode 100644 index 0000000..d5637b3 --- /dev/null +++ b/watchtower/docker-compose.yml @@ -0,0 +1,12 @@ +services: + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WATCHTOWER_SCHEDULE=0 0 4 * * * + - WATCHTOWER_CLEANUP=true + networks: + - homelab diff --git a/wireguard/docker-compose.yml b/wireguard/docker-compose.yml new file mode 100644 index 0000000..7dea45f --- /dev/null +++ b/wireguard/docker-compose.yml @@ -0,0 +1,28 @@ +services: + wireguard: + image: linuxserver/wireguard + container_name: wireguard + cap_add: + - NET_ADMIN + - SYS_MODULE + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/London + - SERVERURL=auto + - SERVERPORT=51820 + - PEERS=5 + - PEERDNS=auto + - INTERNAL_SUBNET=10.13.13.0 + volumes: + - ./config:/config + - /lib/modules:/lib/modules + ports: + - "51820:51820/udp" + networks: + - homelab + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + restart: always + labels: + - "com.centurylinklabs.watchtower.enable=false" diff --git a/wol-api/Dockerfile b/wol-api/Dockerfile new file mode 100644 index 0000000..829bd5f --- /dev/null +++ b/wol-api/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends iputils-ping \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir flask wakeonlan + +COPY app.py . + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/wol-api/app.py b/wol-api/app.py new file mode 100644 index 0000000..1e24873 --- /dev/null +++ b/wol-api/app.py @@ -0,0 +1,113 @@ +from flask import Flask, request, jsonify +from wakeonlan import send_magic_packet +import logging +import os +import subprocess + +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) + +# Format: name:mac:ip,name2:mac2:ip2 +# Example: desktop:AA:BB:CC:DD:EE:FF:192.168.1.10 +# Set WOL_DEVICES in .env – never commit real MACs/IPs +_WOL_DEVICES_RAW = os.getenv("WOL_DEVICES", "") +BROADCAST_IP = os.getenv("WOL_BROADCAST_IP", "255.255.255.255") +WOL_PACKET_COUNT = int(os.getenv("WOL_PACKET_COUNT", "3")) + + +def _parse_devices() -> tuple[dict[str, str], dict[str, str]]: + """Parse WOL_DEVICES env into devices (name->mac) and device_ips (name->ip). + Format: name:mac:ip (mac has colons, so use name:XX:XX:XX:XX:XX:XX:ip) + Example: desktop:AA:BB:CC:DD:EE:FF:192.168.1.10 + """ + devices: dict[str, str] = {} + device_ips: dict[str, str] = {} + for entry in _WOL_DEVICES_RAW.split(","): + entry = entry.strip() + if not entry: + continue + parts = entry.split(":") + if len(parts) >= 8: + name = parts[0] + mac = ":".join(parts[1:7]) + ip = parts[7] + devices[name] = mac + device_ips[name] = ip + elif len(parts) == 3: + name, mac, ip = parts + devices[name] = mac + device_ips[name] = ip + return devices, device_ips + + +DEVICES, DEVICE_IPS = _parse_devices() + + +def _ping_host(ip: str) -> bool: + try: + subprocess.check_output( + ["ping", "-c", "1", "-W", "1", ip], + stderr=subprocess.DEVNULL, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +@app.route("/wake/", methods=["POST"]) +def wake_device(device): + if device not in DEVICES: + return jsonify({"error": "Device not found"}), 404 + + mac_address = DEVICES[device] + try: + for _ in range(max(1, WOL_PACKET_COUNT)): + send_magic_packet(mac_address, ip_address=BROADCAST_IP) + app.logger.info( + f"Magic packet(s) sent to {device} ({mac_address}) via {BROADCAST_IP}" + ) + return jsonify({"message": f"Wake-on-LAN packet sent to {device}"}), 200 + except Exception as e: + app.logger.exception(f"Error sending magic packet to {device}") + return jsonify({"error": f"Failed to send wake packet: {str(e)}"}), 500 + + +@app.route("/devices", methods=["GET"]) +def list_devices(): + return jsonify({"devices": list(DEVICES.keys())}) + + +@app.route("/status", methods=["GET"]) +def device_status(): + """Return devices with MAC, IP, and online status.""" + status = { + name: { + "mac": mac, + "ip": DEVICE_IPS.get(name, ""), + "online": _ping_host(DEVICE_IPS.get(name, "")), + } + for name, mac in DEVICES.items() + } + return jsonify({"devices": status}) + + +@app.route("/debug/", methods=["GET"]) +def debug_device(device): + if device not in DEVICES: + return jsonify({"error": "Device not found"}), 404 + return jsonify({ + "device": device, + "mac_address": DEVICES[device], + "ip": DEVICE_IPS.get(device, ""), + "broadcast_ip": BROADCAST_IP, + "online": _ping_host(DEVICE_IPS.get(device, "")), + }) + + +@app.route("/health", methods=["GET"]) +def health_check(): + return jsonify({"status": "healthy"}), 200 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/wol-api/docker-compose.yml b/wol-api/docker-compose.yml new file mode 100644 index 0000000..7a42187 --- /dev/null +++ b/wol-api/docker-compose.yml @@ -0,0 +1,17 @@ +services: + wol: + build: . + container_name: wol + restart: always + network_mode: host + environment: + - WOL_DEVICES=${WOL_DEVICES} + - WOL_BROADCAST_IP=${WOL_BROADCAST_IP:-192.168.0.255} + - WOL_PACKET_COUNT=${WOL_PACKET_COUNT:-3} + labels: + - "traefik.enable=true" + - "traefik.http.routers.wol.rule=Host(`wol.zea.lt`)" + - "traefik.http.routers.wol.entrypoints=websecure" + - "traefik.http.routers.wol.tls.certresolver=leresolver" + - "traefik.http.routers.wol.middlewares=auth@file" + - "traefik.http.services.wol.loadbalancer.server.port=5000"