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