Initial homelab infrastructure
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=
|
||||||
+30
@@ -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
|
||||||
@@ -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=<same as server>`
|
||||||
|
- **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/<printer-name>`
|
||||||
|
- 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=<openssl rand -hex 16>
|
||||||
|
|
||||||
|
# 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`.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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/<device>", 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)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==2.3.2
|
||||||
|
requests==2.31.0
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>HomeLab Dashboard</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
margin: 1rem auto 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card, .service-card {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:hover, .service-card:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wake {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0dcaf0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="clock" id="clock"></div>
|
||||||
|
<div class="date" id="date"></div>
|
||||||
|
<div class="weather" id="weather">Loading weather...</div>
|
||||||
|
|
||||||
|
<input type="search" id="search" placeholder="Search Google or type a URL..." onkeydown="searchInput(event)" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Services</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="service-card"><a href="https://git.zea.lt">🗂️ Gitea</a></div>
|
||||||
|
<div class="service-card"><a href="https://registry.zea.lt">📦 Docker Registry</a></div>
|
||||||
|
<div class="service-card"><a href="https://traefik.zea.lt">📊 Traefik Dashboard</a></div>
|
||||||
|
<div class="service-card"><a href="https://wol.zea.lt">🖲️ Wake-on-LAN API</a></div>
|
||||||
|
<div class="service-card"><a href="https://kuma.zea.lt">📈 Uptime Kuma</a></div>
|
||||||
|
<div class="service-card"><a href="https://drone.zea.lt">🚀 Drone CI</a></div>
|
||||||
|
<div class="service-card"><a href="https://print.zea.lt">🖨️ Printer (CUPS)</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Devices</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for dev, mac in devices.items() %}
|
||||||
|
<div class="device-card">
|
||||||
|
<div class="device-status">
|
||||||
|
{% if status[dev] %}
|
||||||
|
🟢
|
||||||
|
{% else %}
|
||||||
|
🔴
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h5>{{ dev|capitalize }}</h5>
|
||||||
|
<button
|
||||||
|
class="btn btn-{{ 'secondary' if status[dev] else 'primary' }} btn-wake"
|
||||||
|
{% if status[dev] %}disabled{% endif %}
|
||||||
|
onclick="wake('{{ dev }}')">
|
||||||
|
Wake
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clock & date
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const clock = now.toLocaleTimeString();
|
||||||
|
const date = now.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||||
|
});
|
||||||
|
document.getElementById('clock').textContent = clock;
|
||||||
|
document.getElementById('date').textContent = date;
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// Weather (fetched via server proxy – API key stays server-side)
|
||||||
|
async function fetchWeather() {
|
||||||
|
const res = await fetch('/weather');
|
||||||
|
if (!res.ok) return document.getElementById('weather').textContent = 'Weather unavailable';
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) return document.getElementById('weather').textContent = 'Weather unavailable';
|
||||||
|
const temp = data.main.temp.toFixed(1);
|
||||||
|
const desc = data.weather[0].description;
|
||||||
|
const icon = data.weather[0].icon;
|
||||||
|
const city = data.name;
|
||||||
|
document.getElementById('weather').innerHTML =
|
||||||
|
`<img src="https://openweathermap.org/img/wn/${icon}.png" alt="" /> ${city}: ${temp}°C, ${desc}`;
|
||||||
|
}
|
||||||
|
fetchWeather();
|
||||||
|
|
||||||
|
// Wake device
|
||||||
|
async function wake(device) {
|
||||||
|
const resp = await fetch(`/wake/${device}`, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.message || data.error);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart search
|
||||||
|
function searchInput(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
let q = e.target.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
if (!q.startsWith('http') && q.includes('.') && !q.includes(' ')) {
|
||||||
|
if (!q.startsWith('http://') && !q.startsWith('https://')) q = 'http://' + q;
|
||||||
|
location.href = q;
|
||||||
|
} else {
|
||||||
|
location.href = 'https://www.google.com/search?q=' + encodeURIComponent(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Executable
+36
@@ -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 <gitea_repo_url> <remote_repo_url>}"
|
||||||
|
REMOTE_URL="${2:?Usage: $0 <gitea_repo_url> <remote_repo_url>}"
|
||||||
|
|
||||||
|
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"
|
||||||
Executable
+161
@@ -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/<device>', 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'
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>HomeLab Dashboard</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body class="p-4">
|
||||||
|
<h1>HomeLab Dashboard</h1>
|
||||||
|
|
||||||
|
<h2>Services</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://git.zea.lt" target="_blank">Gitea</a></li>
|
||||||
|
<li><a href="https://registry.zea.lt" target="_blank">Docker Registry</a></li>
|
||||||
|
<li><a href="https://traefik.zea.lt" target="_blank">Traefik Dashboard</a></li>
|
||||||
|
<li><a href="https://wol.zea.lt" target="_blank">Wake-on-LAN API</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Devices</h2>
|
||||||
|
<table class="table table-bordered" style="max-width:600px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device, mac in devices.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ device }}</td>
|
||||||
|
<td>
|
||||||
|
{% if status[device] %}
|
||||||
|
<span class="badge bg-success">Online</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Offline</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="wake('{{ device }}')">Wake</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function wake(device) {
|
||||||
|
const resp = await fetch(`/wake/${device}`, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.message || data.error);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
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"
|
||||||
|
|
||||||
Executable
+200
@@ -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/<device>', 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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "🛑 Stopping homelab services..."
|
||||||
|
docker compose down
|
||||||
|
echo "✅ Services stopped!"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "🔄 Updating homelab services..."
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
echo "✅ Services updated!"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
+113
@@ -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/<device>", 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/<device>", 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)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user